first commit
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../models/jellyfin_models.dart';
|
||||
import 'downloads_helper.dart';
|
||||
import 'finamp_settings_helper.dart';
|
||||
import 'jellyfin_api_helper.dart';
|
||||
|
||||
/// A class that handles returning ImageProviders for Jellyfin items. This class
|
||||
/// only has one static function to handle this, and has no constructors. It's a
|
||||
/// bit of a jank way to do this, so if you know a better way, please let me
|
||||
/// know :)
|
||||
class AlbumImageProvider {
|
||||
static Future<ImageProvider?> init(
|
||||
BaseItemDto item, {
|
||||
int? maxWidth,
|
||||
int? maxHeight,
|
||||
List<BaseItemDto>? itemsToPrecache,
|
||||
BuildContext? context,
|
||||
}) async {
|
||||
assert(itemsToPrecache == null ? true : context != null);
|
||||
if (item.imageId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (itemsToPrecache != null) {
|
||||
for (final itemToPrecache in itemsToPrecache) {
|
||||
init(itemToPrecache, maxWidth: maxWidth, maxHeight: maxHeight)
|
||||
.then((value) {
|
||||
if (value != null) {
|
||||
precacheImage(value, context!);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
final downloadedImage = downloadsHelper.getDownloadedImage(item);
|
||||
|
||||
if (downloadedImage == null) {
|
||||
if (FinampSettingsHelper.finampSettings.isOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Uri? imageUrl = jellyfinApiHelper.getImageUrl(
|
||||
item: item,
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: maxHeight,
|
||||
);
|
||||
|
||||
if (imageUrl == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return NetworkImage(imageUrl.toString());
|
||||
}
|
||||
|
||||
if (await downloadsHelper.verifyDownloadedImage(downloadedImage)) {
|
||||
return FileImage(downloadedImage.file);
|
||||
}
|
||||
|
||||
// If we've got this far, the download image has failed to verify.
|
||||
// We recurse, which will either return a NetworkImage or an error depending
|
||||
// on if the app is offline.
|
||||
return init(item, maxWidth: maxWidth, maxHeight: maxHeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'finamp_user_helper.dart';
|
||||
import 'jellyfin_api_helper.dart';
|
||||
import 'finamp_settings_helper.dart';
|
||||
import 'downloads_helper.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
import 'music_player_background_task.dart';
|
||||
|
||||
/// Just some functions to make talking to AudioService a bit neater.
|
||||
class AudioServiceHelper {
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
final _audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
final audioServiceHelperLogger = Logger("AudioServiceHelper");
|
||||
|
||||
/// Replaces the queue with the given list of items. If startAtIndex is specified, Any items below it
|
||||
/// will be ignored. This is used for when the user taps in the middle of an album to start from that point.
|
||||
Future<void> replaceQueueWithItem({
|
||||
required List<BaseItemDto> itemList,
|
||||
int initialIndex = 0,
|
||||
bool shuffle = false,
|
||||
}) async {
|
||||
try {
|
||||
if (initialIndex > itemList.length) {
|
||||
return Future.error(
|
||||
"startAtIndex is bigger than the itemList! ($initialIndex > ${itemList.length})");
|
||||
}
|
||||
|
||||
List<MediaItem> queue = [];
|
||||
for (BaseItemDto item in itemList) {
|
||||
try {
|
||||
queue.add(await _generateMediaItem(item));
|
||||
} catch (e) {
|
||||
audioServiceHelperLogger.severe(e);
|
||||
}
|
||||
}
|
||||
|
||||
// if (!shuffle) {
|
||||
// // Give the audio service our next initial index so that playback starts
|
||||
// // at that index. We don't do this if shuffling because it causes the
|
||||
// // queue to always start at the start (although you could argue that we
|
||||
// // still should if initialIndex is not 0, but that doesn't happen
|
||||
// // anywhere in this app so oh well).
|
||||
_audioHandler.setNextInitialIndex(initialIndex);
|
||||
// }
|
||||
|
||||
await _audioHandler.updateQueue(queue);
|
||||
|
||||
if (shuffle) {
|
||||
await _audioHandler.setShuffleMode(AudioServiceShuffleMode.all);
|
||||
} else {
|
||||
await _audioHandler.setShuffleMode(AudioServiceShuffleMode.none);
|
||||
}
|
||||
|
||||
_audioHandler.play();
|
||||
} catch (e) {
|
||||
audioServiceHelperLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasQueueItems() {
|
||||
return (_audioHandler.queue.valueOrNull?.length ?? 0) != 0;
|
||||
}
|
||||
|
||||
@Deprecated("Use addQueueItems instead")
|
||||
Future<void> addQueueItem(BaseItemDto item) async {
|
||||
await addQueueItems([item]);
|
||||
}
|
||||
|
||||
Future<void> addQueueItems(List<BaseItemDto> items) async {
|
||||
try {
|
||||
// If the queue is empty (like when the app is first launched), run the
|
||||
// replace queue function instead so that the song gets played
|
||||
if ((_audioHandler.queue.valueOrNull?.length ?? 0) == 0) {
|
||||
await replaceQueueWithItem(itemList: items);
|
||||
return;
|
||||
}
|
||||
|
||||
final mediaItems =
|
||||
await Future.wait(items.map((i) => _generateMediaItem(i)));
|
||||
await _audioHandler.addQueueItems(mediaItems);
|
||||
} catch (e) {
|
||||
audioServiceHelperLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertQueueItemsNext(List<BaseItemDto> items) async {
|
||||
try {
|
||||
// See above comment in addQueueItem
|
||||
if ((_audioHandler.queue.valueOrNull?.length ?? 0) == 0) {
|
||||
await replaceQueueWithItem(itemList: items);
|
||||
return;
|
||||
}
|
||||
|
||||
final mediaItems =
|
||||
await Future.wait(items.map((i) => _generateMediaItem(i)));
|
||||
await _audioHandler.insertQueueItemsNext(mediaItems);
|
||||
} catch (e) {
|
||||
audioServiceHelperLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shuffles every song in the user's current view.
|
||||
Future<void> shuffleAll(bool isFavourite) async {
|
||||
List<BaseItemDto>? items;
|
||||
|
||||
if (FinampSettingsHelper.finampSettings.isOffline) {
|
||||
// If offline, get a shuffled list of songs from _downloadsHelper.
|
||||
// This is a bit inefficient since we have to get all of the songs and
|
||||
// shuffle them before making a sublist, but I couldn't think of a better
|
||||
// way.
|
||||
items = _downloadsHelper.downloadedItems.map((e) => e.song).toList();
|
||||
items.shuffle();
|
||||
if (items.length - 1 >
|
||||
FinampSettingsHelper.finampSettings.songShuffleItemCount) {
|
||||
items = items.sublist(
|
||||
0, FinampSettingsHelper.finampSettings.songShuffleItemCount);
|
||||
}
|
||||
} else {
|
||||
// If online, get all audio items from the user's view
|
||||
items = await _jellyfinApiHelper.getItems(
|
||||
isGenres: false,
|
||||
parentItem: _finampUserHelper.currentUser!.currentView,
|
||||
includeItemTypes: "Audio",
|
||||
filters: isFavourite ? "IsFavorite" : null,
|
||||
limit: FinampSettingsHelper.finampSettings.songShuffleItemCount,
|
||||
sortBy: "Random",
|
||||
);
|
||||
}
|
||||
|
||||
if (items != null) {
|
||||
await replaceQueueWithItem(itemList: items, shuffle: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start instant mix from item.
|
||||
Future<void> startInstantMixForItem(BaseItemDto item) async {
|
||||
List<BaseItemDto>? items;
|
||||
|
||||
try {
|
||||
items = await _jellyfinApiHelper.getInstantMix(item);
|
||||
if (items != null) {
|
||||
await replaceQueueWithItem(itemList: items, shuffle: false);
|
||||
}
|
||||
} catch (e) {
|
||||
audioServiceHelperLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start instant mix from a selection of artists.
|
||||
Future<void> startInstantMixForArtists(List<String> artistIds) async {
|
||||
List<BaseItemDto>? items;
|
||||
|
||||
try {
|
||||
items = await _jellyfinApiHelper.getArtistMix(artistIds);
|
||||
if (items != null) {
|
||||
await replaceQueueWithItem(itemList: items, shuffle: false);
|
||||
}
|
||||
} catch (e) {
|
||||
audioServiceHelperLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start instant mix from a selection of albums.
|
||||
Future<void> startInstantMixForAlbums(List<String> albumIds) async {
|
||||
List<BaseItemDto>? items;
|
||||
|
||||
try {
|
||||
items = await _jellyfinApiHelper.getAlbumMix(albumIds);
|
||||
if (items != null) {
|
||||
await replaceQueueWithItem(itemList: items, shuffle: false);
|
||||
}
|
||||
} catch (e) {
|
||||
audioServiceHelperLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<MediaItem> _generateMediaItem(BaseItemDto item) async {
|
||||
const uuid = Uuid();
|
||||
|
||||
final downloadedSong = _downloadsHelper.getDownloadedSong(item.id);
|
||||
final isDownloaded = downloadedSong == null
|
||||
? false
|
||||
: await _downloadsHelper.verifyDownloadedSong(downloadedSong);
|
||||
|
||||
return MediaItem(
|
||||
id: uuid.v4(),
|
||||
album: item.album ?? "Unknown Album",
|
||||
artist: item.artists?.join(", ") ?? item.albumArtist,
|
||||
artUri: _downloadsHelper.getDownloadedImage(item)?.file.uri ??
|
||||
_jellyfinApiHelper.getImageUrl(item: item),
|
||||
title: item.name ?? "Unknown Name",
|
||||
extras: {
|
||||
// "parentId": item.parentId,
|
||||
// "itemId": item.id,
|
||||
"itemJson": item.toJson(),
|
||||
"shouldTranscode": FinampSettingsHelper.finampSettings.shouldTranscode,
|
||||
"downloadedSongJson": isDownloaded
|
||||
? (_downloadsHelper.getDownloadedSong(item.id))!.toJson()
|
||||
: null,
|
||||
"isOffline": FinampSettingsHelper.finampSettings.isOffline,
|
||||
// TODO: Maybe add transcoding bitrate here?
|
||||
},
|
||||
// Jellyfin returns microseconds * 10 for some reason
|
||||
duration: Duration(
|
||||
microseconds:
|
||||
(item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// A pattern for case-insensitive matching. Used when sanitising logs as
|
||||
/// Chopper logs the base URL in lowercase.
|
||||
class CaseInsensitivePattern implements Pattern {
|
||||
late String matcher;
|
||||
|
||||
CaseInsensitivePattern(String matcher) {
|
||||
this.matcher = matcher.toLowerCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<Match> allMatches(String string, [int start = 0]) {
|
||||
return matcher.allMatches(string.toLowerCase(), start);
|
||||
}
|
||||
|
||||
@override
|
||||
Match? matchAsPrefix(String string, [int start = 0]) {
|
||||
return matcher.matchAsPrefix(string.toLowerCase(), start);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:finamp/services/contains_login.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'case_insensitive_pattern.dart';
|
||||
import 'finamp_user_helper.dart';
|
||||
|
||||
extension CensoredMessage on LogRecord {
|
||||
/// The uncensored log string to be shown to the user
|
||||
String get logString =>
|
||||
"[$loggerName/${level.name}] $time: $message\n\n${stackTrace.toString()}";
|
||||
|
||||
String get loginCensoredMessage => containsLogin ? "LOGIN BODY" : message;
|
||||
|
||||
String get censoredMessage {
|
||||
if (containsLogin) {
|
||||
return loginCensoredMessage;
|
||||
}
|
||||
|
||||
final finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
String workingLogString = logString;
|
||||
|
||||
for (final user in finampUserHelper.finampUsers) {
|
||||
workingLogString = workingLogString.replaceAll(
|
||||
CaseInsensitivePattern(user.baseUrl), "BASEURL");
|
||||
workingLogString = workingLogString.replaceAll(
|
||||
CaseInsensitivePattern(user.accessToken), "TOKEN");
|
||||
}
|
||||
|
||||
return workingLogString;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:chopper/chopper.dart' as chopper;
|
||||
import 'package:chopper/src/chopper_log_record.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// A logger that aggregates the request and response logs from Chopper.
|
||||
/// Once a request (or response) is completed,
|
||||
/// the whole request log is flushed to the delegated chopperLogger.
|
||||
///
|
||||
/// All other operations are delegated by default.
|
||||
class ChopperAggregateLogger implements Logger {
|
||||
final Logger _delegate = chopper.chopperLogger;
|
||||
|
||||
final Map<chopper.Request, StringBuffer> _requests = HashMap();
|
||||
|
||||
final Map<chopper.Response, StringBuffer> _responses = HashMap();
|
||||
|
||||
@override
|
||||
String get name => _delegate.name;
|
||||
|
||||
@override
|
||||
String get fullName => _delegate.fullName;
|
||||
|
||||
@override
|
||||
Logger? get parent => _delegate.parent;
|
||||
|
||||
@override
|
||||
Level get level => _delegate.level;
|
||||
|
||||
@override
|
||||
set level(Level? value) {
|
||||
_delegate.level = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Logger> get children => _delegate.children;
|
||||
|
||||
@override
|
||||
void log(Level logLevel, Object? message,
|
||||
[Object? error, StackTrace? stackTrace, Zone? zone]) {
|
||||
if (message is ChopperLogRecord) {
|
||||
if (message.request != null) {
|
||||
_requests[message.request]?.writeln(message.message);
|
||||
return;
|
||||
} else if (message.response != null) {
|
||||
_responses[message.response]?.writeln(message.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_delegate.log(logLevel, message, error, stackTrace, zone);
|
||||
}
|
||||
|
||||
void onStartRequest(chopper.Request request) {
|
||||
_requests[request] = StringBuffer();
|
||||
}
|
||||
|
||||
void onEndRequest(chopper.Request request) {
|
||||
info(_requests.remove(request)?.toString().trim());
|
||||
}
|
||||
|
||||
void onStartResponse(chopper.Response response) {
|
||||
_responses[response] = StringBuffer();
|
||||
}
|
||||
|
||||
void onEndResponse(chopper.Response response) {
|
||||
info(_responses.remove(response)?.toString().trim());
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Level?> get onLevelChanged => _delegate.onLevelChanged;
|
||||
|
||||
@override
|
||||
Stream<LogRecord> get onRecord => _delegate.onRecord;
|
||||
|
||||
@override
|
||||
void clearListeners() {
|
||||
_delegate.clearListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
bool isLoggable(Level value) {
|
||||
return _delegate.isLoggable(value);
|
||||
}
|
||||
|
||||
@override
|
||||
void finest(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log(Level.FINEST, message, error, stackTrace);
|
||||
|
||||
@override
|
||||
void finer(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log(Level.FINER, message, error, stackTrace);
|
||||
|
||||
@override
|
||||
void fine(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log(Level.FINE, message, error, stackTrace);
|
||||
|
||||
@override
|
||||
void config(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log(Level.CONFIG, message, error, stackTrace);
|
||||
|
||||
@override
|
||||
void info(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log(Level.INFO, message, error, stackTrace);
|
||||
|
||||
@override
|
||||
void warning(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log(Level.WARNING, message, error, stackTrace);
|
||||
|
||||
@override
|
||||
void severe(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log(Level.SEVERE, message, error, stackTrace);
|
||||
|
||||
@override
|
||||
void shout(Object? message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log(Level.SHOUT, message, error, stackTrace);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
extension ContainsLogin on LogRecord {
|
||||
/// Whether or not the log record contains the user's login details.
|
||||
bool get containsLogin => message.contains('{"Username');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
|
||||
class DownloadUpdate {
|
||||
DownloadUpdate({
|
||||
required this.id,
|
||||
required this.status,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final DownloadTaskStatus status;
|
||||
final int progress;
|
||||
}
|
||||
|
||||
/// This stream is used to provide download updates in the UI. A single callback
|
||||
/// in main.dart adds all of flutter_downloader's events to this stream so that
|
||||
/// changes can be easily listened to in widgets.
|
||||
class DownloadUpdateStream {
|
||||
final ReceivePort _port = ReceivePort();
|
||||
// ignore: close_sinks
|
||||
final _controller = StreamController<DownloadUpdate>.broadcast();
|
||||
|
||||
Stream<DownloadUpdate> get stream => _controller.stream;
|
||||
|
||||
void setupSendPort() {
|
||||
IsolateNameServer.registerPortWithName(
|
||||
_port.sendPort, 'downloader_send_port');
|
||||
_port.listen((dynamic data) {
|
||||
String id = data[0];
|
||||
DownloadTaskStatus status = DownloadTaskStatus(data[1]);
|
||||
int progress = data[2];
|
||||
|
||||
add(DownloadUpdate(
|
||||
id: id,
|
||||
status: status,
|
||||
progress: progress,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Add a new download update to the download update stream.
|
||||
void add(DownloadUpdate downloadUpdate) => _controller.add(downloadUpdate);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:finamp/services/censored_log.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path/path.dart' as path_helper;
|
||||
|
||||
class FinampLogsHelper {
|
||||
final List<LogRecord> logs = [];
|
||||
|
||||
void addLog(LogRecord log) {
|
||||
logs.add(log);
|
||||
|
||||
// We don't want to keep logs forever due to memory constraints.
|
||||
if (logs.length > 1000) {
|
||||
logs.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitises all logs and returns a massive string
|
||||
String getSanitisedLogs() {
|
||||
final logsStringBuffer = StringBuffer();
|
||||
|
||||
for (final log in logs) {
|
||||
logsStringBuffer.writeln(log.censoredMessage);
|
||||
}
|
||||
|
||||
return logsStringBuffer.toString();
|
||||
}
|
||||
|
||||
Future<void> copyLogs() async =>
|
||||
await FlutterClipboard.copy(getSanitisedLogs());
|
||||
|
||||
/// Write logs to a file and share the file
|
||||
Future<void> shareLogs() async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempFile = File(path_helper.join(tempDir.path, "finamp-logs.txt"));
|
||||
|
||||
await tempFile.writeAsString(getSanitisedLogs());
|
||||
|
||||
final xFile = XFile(tempFile.path, mimeType: "text/plain");
|
||||
|
||||
await Share.shareXFiles([xFile]);
|
||||
|
||||
await tempFile.delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../models/finamp_models.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
import 'get_internal_song_dir.dart';
|
||||
|
||||
class FinampSettingsHelper {
|
||||
static ValueListenable<Box<FinampSettings>> get finampSettingsListener =>
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.listenable(keys: ["FinampSettings"]);
|
||||
|
||||
// This shouldn't be null as FinampSettings is created on startup.
|
||||
// This decision will probably come back to haunt me later.
|
||||
static FinampSettings get finampSettings =>
|
||||
Hive.box<FinampSettings>("FinampSettings").get("FinampSettings")!;
|
||||
|
||||
/// Deletes the downloadLocation at the given index.
|
||||
static void deleteDownloadLocation(String id) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.downloadLocationsMap.remove(id);
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
/// Add a new download location to FinampSettings
|
||||
static void addDownloadLocation(DownloadLocation downloadLocation) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.downloadLocationsMap[downloadLocation.id] =
|
||||
downloadLocation;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static Future<DownloadLocation> resetDefaultDownloadLocation() async {
|
||||
final newInternalSongDir = await getInternalSongDir();
|
||||
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
final internalSongDownloadLocation = finampSettingsTemp.internalSongDir;
|
||||
|
||||
internalSongDownloadLocation.path = newInternalSongDir.path;
|
||||
finampSettingsTemp.downloadLocationsMap[internalSongDownloadLocation.id] =
|
||||
internalSongDownloadLocation;
|
||||
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
|
||||
return internalSongDownloadLocation;
|
||||
}
|
||||
|
||||
/// Set the isOffline property
|
||||
static void setIsOffline(bool isOffline) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.isOffline = isOffline;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
/// Set the shouldTranscode property
|
||||
static void setShouldTranscode(bool shouldTranscode) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.shouldTranscode = shouldTranscode;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setTranscodeBitrate(int transcodeBitrate) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.transcodeBitrate = transcodeBitrate;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setShowTab(TabContentType tabContentType, bool value) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.showTabs[tabContentType] = value;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setIsFavourite(bool isFavourite) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.isFavourite = isFavourite;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setSortBy(TabContentType tabType, SortBy sortBy) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.tabSortBy[tabType] = sortBy;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setSortOrder(TabContentType tabType, SortOrder sortOrder) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.tabSortOrder[tabType] = sortOrder;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setAndroidStopForegroundOnPause(
|
||||
bool androidStopForegroundOnPause) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.androidStopForegroundOnPause =
|
||||
androidStopForegroundOnPause;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setSongShuffleItemCount(int songShuffleItemCount) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.songShuffleItemCount = songShuffleItemCount;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setContentGridViewCrossAxisCountPortrait(
|
||||
int contentGridViewCrossAxisCountPortrait) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.contentGridViewCrossAxisCountPortrait =
|
||||
contentGridViewCrossAxisCountPortrait;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setContentGridViewCrossAxisCountLandscape(
|
||||
int contentGridViewCrossAxisCountLandscape) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.contentGridViewCrossAxisCountLandscape =
|
||||
contentGridViewCrossAxisCountLandscape;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setContentViewType(ContentViewType contentViewType) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.contentViewType = contentViewType;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setShowTextOnGridView(bool showTextOnGridView) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.showTextOnGridView = showTextOnGridView;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setSleepTimerSeconds(int sleepTimerSeconds) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.sleepTimerSeconds = sleepTimerSeconds;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void overwriteFinampSettings(FinampSettings newFinampSettings) {
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", newFinampSettings);
|
||||
}
|
||||
|
||||
static void setShowCoverAsPlayerBackground(bool showCoverAsPlayerBackground) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.showCoverAsPlayerBackground =
|
||||
showCoverAsPlayerBackground;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setHideSongArtistsIfSameAsAlbumArtists(
|
||||
bool hideSongArtistsIfSameAsAlbumArtists) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.hideSongArtistsIfSameAsAlbumArtists =
|
||||
hideSongArtistsIfSameAsAlbumArtists;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setDisableGesture(bool disableGesture) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.disableGesture = disableGesture;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setShowFastScroller(bool showFastScroller) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.showFastScroller = showFastScroller;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setBufferDuration(Duration bufferDuration) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.bufferDuration = bufferDuration;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setHasCompletedBlurhashImageMigration(
|
||||
bool hasCompletedBlurhashImageMigration) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.hasCompletedBlurhashImageMigration =
|
||||
hasCompletedBlurhashImageMigration;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setHasCompletedBlurhashImageMigrationIdFix(
|
||||
bool hasCompletedBlurhashImageMigrationIdFix) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.hasCompletedBlurhashImageMigrationIdFix =
|
||||
hasCompletedBlurhashImageMigrationIdFix;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void setTabOrder(int index, TabContentType tabContentType) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.tabOrder[index] = tabContentType;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
|
||||
static void resetTabs() {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.tabOrder = TabContentType.values;
|
||||
finampSettingsTemp.showTabs = Map.fromEntries(
|
||||
TabContentType.values.map(
|
||||
(e) => MapEntry(e, true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void setSwipeInsertQueueNext(bool swipeInsertQueueNext) {
|
||||
FinampSettings finampSettingsTemp = finampSettings;
|
||||
finampSettingsTemp.swipeInsertQueueNext = swipeInsertQueueNext;
|
||||
Hive.box<FinampSettings>("FinampSettings")
|
||||
.put("FinampSettings", finampSettingsTemp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
import '../models/finamp_models.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
|
||||
/// Helper class for Finamp users. Note that this class does not talk to the
|
||||
/// Jellyfin server, so stuff like logging in/out is handled in JellyfinApiData.
|
||||
class FinampUserHelper {
|
||||
final _finampUserBox = Hive.box<FinampUser>("FinampUsers");
|
||||
final _currentUserIdBox = Hive.box<String>("CurrentUserId");
|
||||
|
||||
/// Checks if there are any saved users.
|
||||
bool get isUsersEmpty => _finampUserBox.isEmpty;
|
||||
|
||||
/// Loads the id from CurrentUserId. Returns null if no id is stored.
|
||||
String? get currentUserId => _currentUserIdBox.get("CurrentUserId");
|
||||
|
||||
/// Loads the FinampUser with the id from CurrentUserId. Returns null if no
|
||||
/// user exists.
|
||||
FinampUser? get currentUser =>
|
||||
_finampUserBox.get(_currentUserIdBox.get("CurrentUserId"));
|
||||
|
||||
ValueListenable<Box<FinampUser>> get finampUsersListenable =>
|
||||
_finampUserBox.listenable();
|
||||
|
||||
Iterable<FinampUser> get finampUsers => _finampUserBox.values;
|
||||
|
||||
/// Saves a new user to the Hive box and sets the CurrentUserId.
|
||||
Future<void> saveUser(FinampUser newUser) async {
|
||||
await Future.wait([
|
||||
_finampUserBox.put(newUser.id, newUser),
|
||||
_currentUserIdBox.put("CurrentUserId", newUser.id),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Sets the views of the current user
|
||||
void setCurrentUserViews(List<BaseItemDto> newViews) {
|
||||
final currentUserId = _currentUserIdBox.get("CurrentUserId");
|
||||
FinampUser currentUserTemp = currentUser!;
|
||||
|
||||
currentUserTemp.views = Map<String, BaseItemDto>.fromEntries(
|
||||
newViews.map((e) => MapEntry(e.id, e)));
|
||||
currentUserTemp.currentViewId = currentUserTemp.views.keys.first;
|
||||
|
||||
_finampUserBox.put(currentUserId, currentUserTemp);
|
||||
}
|
||||
|
||||
void setCurrentUserCurrentViewId(String newViewId) {
|
||||
final currentUserId = _currentUserIdBox.get("CurrentUserId");
|
||||
FinampUser currentUserTemp = currentUser!;
|
||||
|
||||
currentUserTemp.currentViewId = newViewId;
|
||||
|
||||
_finampUserBox.put(currentUserId, currentUserTemp);
|
||||
}
|
||||
|
||||
/// Removes the user with the given id. If the given id is the current user
|
||||
/// id, CurrentUserId is cleared.
|
||||
void removeUser(String id) {
|
||||
if (id == _currentUserIdBox.get("CurrentUserId")) {
|
||||
_currentUserIdBox.delete("CurrentUserId");
|
||||
}
|
||||
|
||||
_finampUserBox.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../models/jellyfin_models.dart';
|
||||
import 'process_artist.dart';
|
||||
|
||||
/// Creates the subtitle text used on AlbumItemListTile and AlbumItemCard
|
||||
String? generateSubtitle(
|
||||
BaseItemDto item, String? parentType, BuildContext context) {
|
||||
// TODO: Make it so that album subtitle on the artist screen isn't the artist's name (maybe something like the number of songs in the album)
|
||||
|
||||
// If the parentType is MusicArtist, this is being called by an AlbumListTile in an AlbumView of an artist.
|
||||
if (parentType == "MusicArtist") {
|
||||
return item.productionYearString;
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case "MusicAlbum":
|
||||
return item.albumArtists != null && item.albumArtists!.isNotEmpty && (item.albumArtists!.length > 1 || item.albumArtists?.first.name != item.albumArtist) ? item.albumArtists?.map((e) => processArtist(e.name, context)).join(", ") : processArtist(item.albumArtist, context);
|
||||
case "Playlist":
|
||||
return AppLocalizations.of(context)!.songCount(item.childCount!);
|
||||
// case "MusicGenre":
|
||||
// case "MusicArtist":
|
||||
// return Text("${item.albumCount} Albums");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path_helper;
|
||||
|
||||
/// Returns the "internal storage" directory for songs (applicationDocumentsDirectory + /songs).
|
||||
/// If it doesn't exist, the directory is created.
|
||||
Future<Directory> getInternalSongDir() async {
|
||||
// TODO: Start using support directory by default, keep this around for legacy
|
||||
Directory appDir = await getApplicationDocumentsDirectory();
|
||||
Directory songDir = Directory(path_helper.join(appDir.path, "songs"));
|
||||
if (!await songDir.exists()) {
|
||||
await songDir.create();
|
||||
}
|
||||
return songDir;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:chopper/chopper.dart';
|
||||
import 'package:finamp/services/chopper_aggregate_logger.dart';
|
||||
|
||||
final aggregateLogger = ChopperAggregateLogger();
|
||||
|
||||
/// A HttpLoggingInterceptor that aggregates the request and
|
||||
/// response logs from Chopper, using the [ChopperAggregateLogger].
|
||||
class HttpAggregateLoggingInterceptor extends HttpLoggingInterceptor {
|
||||
HttpAggregateLoggingInterceptor({level = Level.body})
|
||||
: super(level: level, logger: aggregateLogger);
|
||||
|
||||
@override
|
||||
FutureOr<Request> onRequest(Request request) async {
|
||||
aggregateLogger.onStartRequest(request);
|
||||
final result = await super.onRequest(request);
|
||||
aggregateLogger.onEndRequest(request);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<Response> onResponse(Response response) {
|
||||
aggregateLogger.onStartResponse(response);
|
||||
final result = super.onResponse(response);
|
||||
aggregateLogger.onEndResponse(response);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'jellyfin_api.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ChopperGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
final class _$JellyfinApi extends JellyfinApi {
|
||||
_$JellyfinApi([ChopperClient? client]) {
|
||||
if (client == null) return;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@override
|
||||
final Type definitionType = JellyfinApi;
|
||||
|
||||
@override
|
||||
Future<dynamic> getPublicUsers() async {
|
||||
final Uri $url = Uri.parse('/Users/Public');
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> authenticateViaName(
|
||||
Map<String, String> usernameAndPassword) async {
|
||||
final Uri $url = Uri.parse('/Users/AuthenticateByName');
|
||||
final $body = usernameAndPassword;
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
body: $body,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getAlbumPrimaryImage({
|
||||
required String id,
|
||||
String format = "webp",
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Items/${id}/Images/Primary');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{'format': format};
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getViews(String id) async {
|
||||
final Uri $url = Uri.parse('/Users/${id}/Views');
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getItems({
|
||||
required String userId,
|
||||
String? includeItemTypes,
|
||||
String? parentId,
|
||||
String? albumArtistIds,
|
||||
String? artistIds,
|
||||
String? albumIds,
|
||||
bool? recursive,
|
||||
String? sortBy,
|
||||
String? sortOrder,
|
||||
String? fields = defaultFields,
|
||||
String? searchTerm,
|
||||
String? genreIds,
|
||||
String? filters,
|
||||
int? startIndex,
|
||||
int? limit,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Users/${userId}/Items');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{
|
||||
'IncludeItemTypes': includeItemTypes,
|
||||
'ParentId': parentId,
|
||||
'AlbumArtistIds': albumArtistIds,
|
||||
'ArtistIds': artistIds,
|
||||
'AlbumIds': albumIds,
|
||||
'Recursive': recursive,
|
||||
'SortBy': sortBy,
|
||||
'SortOrder': sortOrder,
|
||||
'Fields': fields,
|
||||
'SearchTerm': searchTerm,
|
||||
'GenreIds': genreIds,
|
||||
'Filters': filters,
|
||||
'StartIndex': startIndex,
|
||||
'Limit': limit,
|
||||
};
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getInstantMix({
|
||||
required String id,
|
||||
required String userId,
|
||||
required int limit,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Items/${id}/InstantMix');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{
|
||||
'userId': userId,
|
||||
'limit': limit,
|
||||
};
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getItemById({
|
||||
required String userId,
|
||||
required String itemId,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Users/${userId}/Items/${itemId}');
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getPlaybackInfo({
|
||||
required String id,
|
||||
required String userId,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Items/${id}/PlaybackInfo');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{'userId': userId};
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> updateItem({
|
||||
required String itemId,
|
||||
required BaseItemDto newItem,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Items/${itemId}');
|
||||
final $body = newItem;
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
body: $body,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> startPlayback(
|
||||
PlaybackProgressInfo playbackProgressInfo) async {
|
||||
final Uri $url = Uri.parse('/Sessions/Playing');
|
||||
final $body = playbackProgressInfo;
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
body: $body,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> playbackStatusUpdate(
|
||||
PlaybackProgressInfo playbackProgressInfo) async {
|
||||
final Uri $url = Uri.parse('/Sessions/Playing/Progress');
|
||||
final $body = playbackProgressInfo;
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
body: $body,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> playbackStatusStopped(
|
||||
PlaybackProgressInfo playbackProgressInfo) async {
|
||||
final Uri $url = Uri.parse('/Sessions/Playing/Stopped');
|
||||
final $body = playbackProgressInfo;
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
body: $body,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getPlaylistItems({
|
||||
required String playlistId,
|
||||
required String userId,
|
||||
String? includeItemTypes,
|
||||
String? parentId,
|
||||
bool? recursive,
|
||||
String? fields = defaultFields,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Playlists/${playlistId}/Items');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{
|
||||
'UserId': userId,
|
||||
'IncludeItemTypes': includeItemTypes,
|
||||
'ParentId': parentId,
|
||||
'Recursive': recursive,
|
||||
'Fields': fields,
|
||||
};
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> createNewPlaylist({required NewPlaylist newPlaylist}) async {
|
||||
final Uri $url = Uri.parse('/Playlists');
|
||||
final $body = newPlaylist;
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
body: $body,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<dynamic>> addItemsToPlaylist({
|
||||
required String playlistId,
|
||||
String? ids,
|
||||
String? userId,
|
||||
}) {
|
||||
final Uri $url = Uri.parse('/Playlists/${playlistId}/Items');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{
|
||||
'ids': ids,
|
||||
'userId': userId,
|
||||
};
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
return client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<dynamic>> removeItemsFromPlaylist({
|
||||
required String playlistId,
|
||||
String? entryIds,
|
||||
}) {
|
||||
final Uri $url = Uri.parse('/Playlists/${playlistId}/Items');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{
|
||||
'entryIds': entryIds
|
||||
};
|
||||
final Request $request = Request(
|
||||
'DELETE',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
return client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getAlbumArtists({
|
||||
String? includeItemTypes,
|
||||
String? parentId,
|
||||
bool? recursive,
|
||||
String? sortBy,
|
||||
String? sortOrder,
|
||||
String? fields = defaultFields,
|
||||
String? searchTerm,
|
||||
bool enableUserData = true,
|
||||
String? filters,
|
||||
int? startIndex,
|
||||
int? limit,
|
||||
required String userId,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Artists/AlbumArtists');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{
|
||||
'IncludeItemTypes': includeItemTypes,
|
||||
'ParentId': parentId,
|
||||
'Recursive': recursive,
|
||||
'SortBy': sortBy,
|
||||
'SortOrder': sortOrder,
|
||||
'Fields': fields,
|
||||
'SearchTerm': searchTerm,
|
||||
'EnableUserData': enableUserData,
|
||||
'Filters': filters,
|
||||
'StartIndex': startIndex,
|
||||
'Limit': limit,
|
||||
'UserId': userId,
|
||||
};
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> getGenres({
|
||||
String? includeItemTypes,
|
||||
String? parentId,
|
||||
String? fields = defaultFields,
|
||||
String? searchTerm,
|
||||
int? startIndex,
|
||||
int? limit,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Genres');
|
||||
final Map<String, dynamic> $params = <String, dynamic>{
|
||||
'IncludeItemTypes': includeItemTypes,
|
||||
'ParentId': parentId,
|
||||
'Fields': fields,
|
||||
'SearchTerm': searchTerm,
|
||||
'StartIndex': startIndex,
|
||||
'Limit': limit,
|
||||
};
|
||||
final Request $request = Request(
|
||||
'GET',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
parameters: $params,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> addFavourite({
|
||||
required String userId,
|
||||
required String itemId,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Users/${userId}/FavoriteItems/${itemId}');
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> removeFavourite({
|
||||
required String userId,
|
||||
required String itemId,
|
||||
}) async {
|
||||
final Uri $url = Uri.parse('/Users/${userId}/FavoriteItems/${itemId}');
|
||||
final Request $request = Request(
|
||||
'DELETE',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
responseConverter: JsonConverter.responseFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> logout() async {
|
||||
final Uri $url = Uri.parse('/Sessions/Logout');
|
||||
final Request $request = Request(
|
||||
'POST',
|
||||
$url,
|
||||
client.baseUrl,
|
||||
);
|
||||
final Response $response = await client.send<dynamic, dynamic>(
|
||||
$request,
|
||||
requestConverter: JsonConverter.requestFactory,
|
||||
);
|
||||
return $response.bodyOrThrow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:android_id/android_id.dart';
|
||||
import 'package:chopper/chopper.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:finamp/services/http_aggregate_logging_interceptor.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../models/jellyfin_models.dart';
|
||||
import 'finamp_user_helper.dart';
|
||||
import 'jellyfin_api_helper.dart';
|
||||
|
||||
part 'jellyfin_api.chopper.dart';
|
||||
|
||||
const String defaultFields =
|
||||
"ChildCount,DateCreated,DateLastMediaAdded,Etag,Genres,ParentId,ProviderIds,Tags";
|
||||
|
||||
@ChopperApi()
|
||||
abstract class JellyfinApi extends ChopperService {
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Users/Public")
|
||||
Future<dynamic> getPublicUsers();
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Post(path: "/Users/AuthenticateByName")
|
||||
Future<dynamic> authenticateViaName(
|
||||
@Body() Map<String, String> usernameAndPassword);
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Items/{id}/Images/Primary")
|
||||
Future<dynamic> getAlbumPrimaryImage({
|
||||
@Path() required String id,
|
||||
@Query() String format = "webp",
|
||||
});
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Users/{id}/Views")
|
||||
Future<dynamic> getViews(@Path() String id);
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Users/{userId}/Items")
|
||||
Future<dynamic> getItems({
|
||||
/// The user id supplied as query parameter.
|
||||
@Path() required String userId,
|
||||
|
||||
/// Optional. If specified, results will be filtered based on the item type.
|
||||
/// This allows multiple, comma delimeted.
|
||||
@Query("IncludeItemTypes") String? includeItemTypes,
|
||||
|
||||
/// Specify this to localize the search to a specific item or folder. Omit
|
||||
/// to use the root.
|
||||
@Query("ParentId") String? parentId,
|
||||
|
||||
/// Optional. If specified, results will be filtered to include only those
|
||||
/// containing the specified album artist id.
|
||||
@Query("AlbumArtistIds") String? albumArtistIds,
|
||||
|
||||
/// Optional. If specified, results will be filtered to include only those
|
||||
/// containing the specified artist id.
|
||||
@Query("ArtistIds") String? artistIds,
|
||||
|
||||
/// Optional. If specified, results will be filtered to include only those
|
||||
/// containing the specified album id.
|
||||
@Query("AlbumIds") String? albumIds,
|
||||
|
||||
/// When searching within folders, this determines whether or not the search
|
||||
/// will be recursive. true/false.
|
||||
@Query("Recursive") bool? recursive,
|
||||
|
||||
/// Optional. Specify one or more sort orders, comma delimited. Options:
|
||||
/// Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating,
|
||||
/// DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear,
|
||||
/// SortName, Random, Revenue, Runtime.
|
||||
@Query("SortBy") String? sortBy,
|
||||
|
||||
/// Items Enum: "Ascending" "Descending"
|
||||
/// Sort Order - Ascending,Descending.
|
||||
@Query("SortOrder") String? sortOrder,
|
||||
|
||||
/// Items Enum: "AirTime" "CanDelete" "CanDownload" "ChannelInfo" "Chapters"
|
||||
/// "ChildCount" "CumulativeRunTimeTicks" "CustomRating" "DateCreated"
|
||||
/// "DateLastMediaAdded" "DisplayPreferencesId" "Etag" "ExternalUrls"
|
||||
/// "Genres" "HomePageUrl" "ItemCounts" "MediaSourceCount" "MediaSources"
|
||||
/// "OriginalTitle" "Overview" "ParentId" "Path" "People" "PlayAccess"
|
||||
/// "ProductionLocations" "ProviderIds" "PrimaryImageAspectRatio"
|
||||
/// "RecursiveItemCount" "Settings" "ScreenshotImageTags"
|
||||
/// "SeriesPrimaryImage" "SeriesStudio" "SortName" "SpecialEpisodeNumbers"
|
||||
/// "Studios" "BasicSyncInfo" "SyncInfo" "Taglines" "Tags" "RemoteTrailers"
|
||||
/// "MediaStreams" "SeasonUserData" "ServiceName" "ThemeSongIds"
|
||||
/// "ThemeVideoIds" "ExternalEtag" "PresentationUniqueKey"
|
||||
/// "InheritedParentalRatingValue" "ExternalSeriesId"
|
||||
/// "SeriesPresentationUniqueKey" "DateLastRefreshed" "DateLastSaved"
|
||||
/// "RefreshState" "ChannelImage" "EnableMediaSourceDisplay" "Width"
|
||||
/// "Height" "ExtraIds" "LocalTrailerCount" "IsHD" "SpecialFeatureCount"
|
||||
@Query("Fields") String? fields = defaultFields,
|
||||
|
||||
/// Optional. Filter based on a search term.
|
||||
@Query("SearchTerm") String? searchTerm,
|
||||
|
||||
/// Optional. If specified, results will be filtered based on genre id. This
|
||||
/// allows multiple, pipe delimited.
|
||||
@Query("GenreIds") String? genreIds,
|
||||
|
||||
/// Items Enum: "IsFolder" "IsNotFolder" "IsUnplayed" "IsPlayed"
|
||||
/// "IsFavorite" "IsResumable" "Likes" "Dislikes" "IsFavoriteOrLikes"
|
||||
/// Optional. Specify additional filters to apply. This allows multiple,
|
||||
/// comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed,
|
||||
/// IsFavorite, IsResumable, Likes, Dislikes.
|
||||
@Query("Filters") String? filters,
|
||||
|
||||
/// Optional. The record index to start at. All items with a lower index
|
||||
/// will be dropped from the results.
|
||||
@Query("StartIndex") int? startIndex,
|
||||
|
||||
/// Optional. The maximum number of records to return.
|
||||
@Query("Limit") int? limit,
|
||||
});
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Items/{id}/InstantMix")
|
||||
Future<dynamic> getInstantMix({
|
||||
@Path() required String id,
|
||||
@Query() required String userId,
|
||||
@Query() required int limit,
|
||||
});
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Users/{userId}/Items/{itemId}")
|
||||
Future<dynamic> getItemById({
|
||||
/// User id.
|
||||
@Path() required String userId,
|
||||
|
||||
/// Item id.
|
||||
@Path() required String itemId,
|
||||
});
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Items/{id}/PlaybackInfo")
|
||||
Future<dynamic> getPlaybackInfo({
|
||||
@Path() required String id,
|
||||
@Query() required String userId,
|
||||
});
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
)
|
||||
@Post(path: "/Items/{itemId}")
|
||||
Future<dynamic> updateItem({
|
||||
/// The item id.
|
||||
@Path() required String itemId,
|
||||
@Body() required BaseItemDto newItem,
|
||||
});
|
||||
|
||||
@FactoryConverter(request: JsonConverter.requestFactory)
|
||||
@Post(path: "/Sessions/Playing")
|
||||
Future<dynamic> startPlayback(
|
||||
@Body() PlaybackProgressInfo playbackProgressInfo);
|
||||
|
||||
@FactoryConverter(request: JsonConverter.requestFactory)
|
||||
@Post(path: "/Sessions/Playing/Progress")
|
||||
Future<dynamic> playbackStatusUpdate(
|
||||
@Body() PlaybackProgressInfo playbackProgressInfo);
|
||||
|
||||
@FactoryConverter(request: JsonConverter.requestFactory)
|
||||
@Post(path: "/Sessions/Playing/Stopped")
|
||||
Future<dynamic> playbackStatusStopped(
|
||||
@Body() PlaybackProgressInfo playbackProgressInfo);
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Playlists/{playlistId}/Items")
|
||||
Future<dynamic> getPlaylistItems({
|
||||
@Path() required String playlistId,
|
||||
@Query("UserId") required String userId,
|
||||
@Query("IncludeItemTypes") String? includeItemTypes,
|
||||
@Query("ParentId") String? parentId,
|
||||
@Query("Recursive") bool? recursive,
|
||||
@Query("Fields") String? fields = defaultFields,
|
||||
});
|
||||
|
||||
/// Creates a new playlist.
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Post(path: "/Playlists")
|
||||
Future<dynamic> createNewPlaylist({
|
||||
/// The create playlist payload.
|
||||
@Body() required NewPlaylist newPlaylist,
|
||||
});
|
||||
|
||||
/// Adds items to a playlist.
|
||||
@FactoryConverter(request: JsonConverter.requestFactory)
|
||||
@Post(path: "/Playlists/{playlistId}/Items", optionalBody: true)
|
||||
Future<Response> addItemsToPlaylist({
|
||||
/// The playlist id.
|
||||
@Path() required String playlistId,
|
||||
|
||||
/// Item id, comma delimited.
|
||||
@Query() String? ids,
|
||||
|
||||
/// The userId.
|
||||
@Query() String? userId,
|
||||
});
|
||||
|
||||
/// Remove items from a playlist.
|
||||
@FactoryConverter(request: JsonConverter.requestFactory)
|
||||
@Delete(path: "/Playlists/{playlistId}/Items", optionalBody: true)
|
||||
Future<Response> removeItemsFromPlaylist({
|
||||
/// The playlist id.
|
||||
@Path() required String playlistId,
|
||||
|
||||
/// Item id, comma delimited.
|
||||
@Query() String? entryIds,
|
||||
});
|
||||
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Artists/AlbumArtists")
|
||||
Future<dynamic> getAlbumArtists({
|
||||
@Query("IncludeItemTypes") String? includeItemTypes,
|
||||
@Query("ParentId") String? parentId,
|
||||
@Query("Recursive") bool? recursive,
|
||||
|
||||
/// Optional. Specify one or more sort orders, comma delimited. Options:
|
||||
/// Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating,
|
||||
/// DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear,
|
||||
/// SortName, Random, Revenue, Runtime.
|
||||
@Query("SortBy") String? sortBy,
|
||||
|
||||
/// Items Enum: "Ascending" "Descending"
|
||||
/// Sort Order - Ascending,Descending.
|
||||
@Query("SortOrder") String? sortOrder,
|
||||
@Query("Fields") String? fields = defaultFields,
|
||||
@Query("SearchTerm") String? searchTerm,
|
||||
@Query("EnableUserData") bool enableUserData = true,
|
||||
|
||||
/// Items Enum: "IsFolder" "IsNotFolder" "IsUnplayed" "IsPlayed"
|
||||
/// "IsFavorite" "IsResumable" "Likes" "Dislikes" "IsFavoriteOrLikes"
|
||||
/// Optional. Specify additional filters to apply.
|
||||
@Query("Filters") String? filters,
|
||||
|
||||
/// Optional. The record index to start at. All items with a lower index
|
||||
/// will be dropped from the results.
|
||||
@Query("StartIndex") int? startIndex,
|
||||
|
||||
/// Optional. The maximum number of records to return.
|
||||
@Query("Limit") int? limit,
|
||||
|
||||
/// User id. Technically nullable in the Jellyfin API docs, but getting
|
||||
/// favourited artists will break if this is not given.
|
||||
@Query("UserId") required String userId,
|
||||
});
|
||||
|
||||
/// Gets all genres from a given item, folder, or the entire library.
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Get(path: "/Genres")
|
||||
Future<dynamic> getGenres({
|
||||
/// Optional. If specified, results will be filtered based on the item type.
|
||||
/// This allows multiple, comma delimeted.
|
||||
@Query("IncludeItemTypes") String? includeItemTypes,
|
||||
|
||||
/// Specify this to localize the search to a specific item or folder. Omit
|
||||
/// to use the root.
|
||||
@Query("ParentId") String? parentId,
|
||||
|
||||
/// Items Enum: "AirTime" "CanDelete" "CanDownload" "ChannelInfo" "Chapters"
|
||||
/// "ChildCount" "CumulativeRunTimeTicks" "CustomRating" "DateCreated"
|
||||
/// "DateLastMediaAdded" "DisplayPreferencesId" "Etag" "ExternalUrls"
|
||||
/// "Genres" "HomePageUrl" "ItemCounts" "MediaSourceCount" "MediaSources"
|
||||
/// "OriginalTitle" "Overview" "ParentId" "Path" "People" "PlayAccess"
|
||||
/// "ProductionLocations" "ProviderIds" "PrimaryImageAspectRatio"
|
||||
/// "RecursiveItemCount" "Settings" "ScreenshotImageTags"
|
||||
/// "SeriesPrimaryImage" "SeriesStudio" "SortName" "SpecialEpisodeNumbers"
|
||||
/// "Studios" "BasicSyncInfo" "SyncInfo" "Taglines" "Tags" "RemoteTrailers"
|
||||
/// "MediaStreams" "SeasonUserData" "ServiceName" "ThemeSongIds"
|
||||
/// "ThemeVideoIds" "ExternalEtag" "PresentationUniqueKey"
|
||||
/// "InheritedParentalRatingValue" "ExternalSeriesId"
|
||||
/// "SeriesPresentationUniqueKey" "DateLastRefreshed" "DateLastSaved"
|
||||
/// "RefreshState" "ChannelImage" "EnableMediaSourceDisplay" "Width"
|
||||
/// "Height" "ExtraIds" "LocalTrailerCount" "IsHD" "SpecialFeatureCount"
|
||||
@Query("Fields") String? fields = defaultFields,
|
||||
|
||||
/// Optional. Filter based on a search term.
|
||||
@Query("SearchTerm") String? searchTerm,
|
||||
|
||||
/// Optional. The record index to start at. All items with a lower index
|
||||
/// will be dropped from the results.
|
||||
@Query("StartIndex") int? startIndex,
|
||||
|
||||
/// Optional. The maximum number of records to return.
|
||||
@Query("Limit") int? limit,
|
||||
});
|
||||
|
||||
/// Marks an item as a favorite.
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Post(path: "/Users/{userId}/FavoriteItems/{itemId}", optionalBody: true)
|
||||
Future<dynamic> addFavourite({
|
||||
/// User id.
|
||||
@Path() required String userId,
|
||||
|
||||
/// Item id.
|
||||
@Path() required String itemId,
|
||||
});
|
||||
|
||||
/// Unmarks item as a favorite.
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
response: JsonConverter.responseFactory,
|
||||
)
|
||||
@Delete(path: "/Users/{userId}/FavoriteItems/{itemId}")
|
||||
Future<dynamic> removeFavourite({
|
||||
/// User id.
|
||||
@Path() required String userId,
|
||||
|
||||
/// Item id.
|
||||
@Path() required String itemId,
|
||||
});
|
||||
|
||||
/// Reports that a session has ended.
|
||||
@FactoryConverter(
|
||||
request: JsonConverter.requestFactory,
|
||||
)
|
||||
@Post(path: "/Sessions/Logout", optionalBody: true)
|
||||
Future<dynamic> logout();
|
||||
|
||||
static JellyfinApi create() {
|
||||
final client = ChopperClient(
|
||||
// The first part of the URL is now here
|
||||
services: [
|
||||
// The generated implementation
|
||||
_$JellyfinApi(),
|
||||
],
|
||||
// Converts data to & from JSON and adds the application/json header.
|
||||
// converter: JsonConverter(),
|
||||
interceptors: [
|
||||
/// Gets baseUrl from SharedPreferences.
|
||||
(Request request) async {
|
||||
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
String authHeader = await getAuthHeader();
|
||||
|
||||
// If baseUrlTemp is null, use the baseUrl of the current user.
|
||||
// If baseUrlTemp is set, we're setting up a new user and should use it instead.
|
||||
Uri baseUri = jellyfinApiHelper.baseUrlTemp ??
|
||||
Uri.parse(finampUserHelper.currentUser!.baseUrl);
|
||||
|
||||
// Add the request path on to the baseUrl
|
||||
baseUri = baseUri.replace(
|
||||
pathSegments:
|
||||
baseUri.pathSegments.followedBy(request.uri.pathSegments));
|
||||
|
||||
return request.copyWith(
|
||||
uri: baseUri,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": authHeader,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
HttpAggregateLoggingInterceptor(),
|
||||
],
|
||||
);
|
||||
|
||||
// The generated class with the ChopperClient passed in
|
||||
return _$JellyfinApi(client);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the Authorization header
|
||||
Future<String> getAuthHeader() async {
|
||||
final notAsciiRegex = RegExp(r'[^\x00-\x7F]+');
|
||||
|
||||
final finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
String authHeader = "MediaBrowser ";
|
||||
|
||||
if (finampUserHelper.currentUser != null) {
|
||||
authHeader = '${authHeader}UserId="${finampUserHelper.currentUser!.id}", ';
|
||||
}
|
||||
|
||||
if (finampUserHelper.currentUser?.accessToken != null) {
|
||||
authHeader =
|
||||
'${authHeader}Token="${finampUserHelper.currentUser!.accessToken}", ';
|
||||
}
|
||||
|
||||
authHeader = '${authHeader}Client="Finamp", ';
|
||||
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
AndroidDeviceInfo androidDeviceInfo = await deviceInfo.androidInfo;
|
||||
authHeader = '${authHeader}Device="${androidDeviceInfo.model}", ';
|
||||
final androidId = await const AndroidId().getId();
|
||||
authHeader = '${authHeader}DeviceId="$androidId", ';
|
||||
} else if (Platform.isIOS) {
|
||||
IosDeviceInfo iosDeviceInfo = await deviceInfo.iosInfo;
|
||||
authHeader = '${authHeader}Device="${iosDeviceInfo.name}", ';
|
||||
authHeader =
|
||||
'${authHeader}DeviceId="${iosDeviceInfo.identifierForVendor}", ';
|
||||
} else {
|
||||
throw "getAuthHeader() only supports Android and iOS";
|
||||
}
|
||||
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
authHeader = '${authHeader}Version="${packageInfo.version}"';
|
||||
|
||||
// In some cases non-ASCII characters can end up in the header, usually via
|
||||
// iOS device name
|
||||
return authHeader.replaceAll(notAsciiRegex, "_");
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'package:chopper/chopper.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'finamp_user_helper.dart';
|
||||
import 'jellyfin_api.dart';
|
||||
import '../models/finamp_models.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
|
||||
class JellyfinApiHelper {
|
||||
final jellyfinApi = JellyfinApi.create();
|
||||
final _jellyfinApiHelperLogger = Logger("JellyfinApiHelper");
|
||||
|
||||
// Stores the ids of the artists that the user selected to mix
|
||||
List<String> selectedMixArtistsIds = [];
|
||||
|
||||
// Stores the ids of albums that the user selected to mix
|
||||
List<String> selectedMixAlbumIds = [];
|
||||
|
||||
Uri? baseUrlTemp;
|
||||
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
Future<List<BaseItemDto>?> getItems({
|
||||
BaseItemDto? parentItem,
|
||||
String? includeItemTypes,
|
||||
String? sortBy,
|
||||
String? sortOrder,
|
||||
String? searchTerm,
|
||||
required bool isGenres,
|
||||
String? filters,
|
||||
|
||||
/// The record index to start at. All items with a lower index will be
|
||||
/// dropped from the results.
|
||||
int? startIndex,
|
||||
|
||||
/// The maximum number of records to return.
|
||||
int? limit,
|
||||
}) async {
|
||||
final currentUser = _finampUserHelper.currentUser;
|
||||
if (currentUser == null) {
|
||||
// When logging out, this request causes errors since currentUser is
|
||||
// required sometimes. We just return an empty list since this error
|
||||
// usually happens becuase the listeners on MusicScreenTabView update
|
||||
// milliseconds before the page is popped. This shouldn't happen in normal
|
||||
// use.
|
||||
return [];
|
||||
}
|
||||
|
||||
var response;
|
||||
|
||||
// We send a different request for playlists so that we get them back in the
|
||||
// right order. Doing this in the same function makes sense since they both
|
||||
// return the same thing. It also means we can easily share album widgets
|
||||
// with playlists.
|
||||
if (parentItem?.type == "Playlist") {
|
||||
response = await jellyfinApi.getPlaylistItems(
|
||||
playlistId: parentItem!.id,
|
||||
userId: currentUser.id,
|
||||
parentId: parentItem.id,
|
||||
includeItemTypes: includeItemTypes,
|
||||
recursive: true,
|
||||
);
|
||||
} else if (includeItemTypes == "MusicArtist") {
|
||||
// For artists, we need to use a different endpoint
|
||||
response = await jellyfinApi.getAlbumArtists(
|
||||
parentId: parentItem?.id,
|
||||
recursive: true,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
searchTerm: searchTerm,
|
||||
filters: filters,
|
||||
startIndex: startIndex,
|
||||
limit: limit,
|
||||
userId: currentUser.id,
|
||||
);
|
||||
} else if (parentItem?.type == "MusicArtist") {
|
||||
// For getting the children of artists, we need to use albumArtistIds
|
||||
// instead of parentId
|
||||
response = await jellyfinApi.getItems(
|
||||
userId: currentUser.id,
|
||||
albumArtistIds: parentItem?.id,
|
||||
includeItemTypes: includeItemTypes,
|
||||
recursive: true,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
searchTerm: searchTerm,
|
||||
filters: filters,
|
||||
startIndex: startIndex,
|
||||
limit: limit,
|
||||
);
|
||||
} else if (includeItemTypes == "MusicGenre") {
|
||||
response = await jellyfinApi.getGenres(
|
||||
parentId: parentItem?.id,
|
||||
// includeItemTypes: includeItemTypes,
|
||||
searchTerm: searchTerm,
|
||||
startIndex: startIndex,
|
||||
limit: limit,
|
||||
);
|
||||
} else if (parentItem?.type == "MusicGenre") {
|
||||
response = await jellyfinApi.getItems(
|
||||
userId: currentUser.id,
|
||||
genreIds: parentItem?.id,
|
||||
includeItemTypes: includeItemTypes,
|
||||
recursive: true,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
searchTerm: searchTerm,
|
||||
filters: filters,
|
||||
startIndex: startIndex,
|
||||
limit: limit,
|
||||
);
|
||||
} else {
|
||||
// This will be run when getting albums, songs in albums, and stuff like
|
||||
// that.
|
||||
response = await jellyfinApi.getItems(
|
||||
userId: currentUser.id,
|
||||
parentId: parentItem?.id,
|
||||
includeItemTypes: includeItemTypes,
|
||||
recursive: true,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
searchTerm: searchTerm,
|
||||
filters: filters,
|
||||
startIndex: startIndex,
|
||||
limit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
return (QueryResult_BaseItemDto.fromJson(response).items);
|
||||
}
|
||||
|
||||
/// Authenticates a user and saves the login details
|
||||
Future<void> authenticateViaName({
|
||||
required String username,
|
||||
String? password,
|
||||
}) async {
|
||||
var response;
|
||||
|
||||
// Some users won't have a password.
|
||||
if (password == null) {
|
||||
response = await jellyfinApi.authenticateViaName({"Username": username});
|
||||
} else {
|
||||
response = await jellyfinApi
|
||||
.authenticateViaName({"Username": username, "Pw": password});
|
||||
}
|
||||
|
||||
AuthenticationResult newUserAuthenticationResult =
|
||||
AuthenticationResult.fromJson(response);
|
||||
|
||||
FinampUser newUser = FinampUser(
|
||||
id: newUserAuthenticationResult.user!.id,
|
||||
baseUrl: baseUrlTemp!.toString(),
|
||||
accessToken: newUserAuthenticationResult.accessToken!,
|
||||
serverId: newUserAuthenticationResult.serverId!,
|
||||
views: {},
|
||||
);
|
||||
|
||||
await _finampUserHelper.saveUser(newUser);
|
||||
}
|
||||
|
||||
/// Gets all the user's views.
|
||||
Future<List<BaseItemDto>> getViews() async {
|
||||
var response =
|
||||
await jellyfinApi.getViews(_finampUserHelper.currentUser!.id);
|
||||
|
||||
return QueryResult_BaseItemDto.fromJson(response).items!;
|
||||
}
|
||||
|
||||
/// Gets the playback info for an item, such as format and bitrate. Usually, I'd require a BaseItemDto as an argument
|
||||
/// but since this will be run inside of [MusicPlayerBackgroundTask], I've just set the raw id as an argument.
|
||||
Future<List<MediaSourceInfo>?> getPlaybackInfo(String itemId) async {
|
||||
var response = await jellyfinApi.getPlaybackInfo(
|
||||
id: itemId,
|
||||
userId: _finampUserHelper.currentUser!.id,
|
||||
);
|
||||
|
||||
// getPlaybackInfo returns a PlaybackInfoResponse. We only need the List<MediaSourceInfo> in it so we convert it here and
|
||||
// return that List<MediaSourceInfo>.
|
||||
final PlaybackInfoResponse decodedResponse =
|
||||
PlaybackInfoResponse.fromJson(response);
|
||||
return decodedResponse.mediaSources;
|
||||
}
|
||||
|
||||
/// Starts an instant mix using the data from the item provided.
|
||||
Future<List<BaseItemDto>?> getInstantMix(BaseItemDto? parentItem) async {
|
||||
var response = await jellyfinApi.getInstantMix(
|
||||
id: parentItem!.id,
|
||||
userId: _finampUserHelper.currentUser!.id,
|
||||
limit: 200);
|
||||
|
||||
return (QueryResult_BaseItemDto.fromJson(response).items);
|
||||
}
|
||||
|
||||
/// Tells the Jellyfin server that playback has started
|
||||
Future<void> reportPlaybackStart(
|
||||
PlaybackProgressInfo playbackProgressInfo) async {
|
||||
await jellyfinApi.startPlayback(playbackProgressInfo);
|
||||
}
|
||||
|
||||
/// Updates player progress so that Jellyfin can track what we're listening to
|
||||
Future<void> updatePlaybackProgress(
|
||||
PlaybackProgressInfo playbackProgressInfo) async {
|
||||
await jellyfinApi.playbackStatusUpdate(playbackProgressInfo);
|
||||
}
|
||||
|
||||
/// Tells Jellyfin that we've stopped listening to music (called when the audio service is stopped)
|
||||
Future<void> stopPlaybackProgress(
|
||||
PlaybackProgressInfo playbackProgressInfo) async {
|
||||
await jellyfinApi.playbackStatusStopped(playbackProgressInfo);
|
||||
}
|
||||
|
||||
/// Gets an item from a user's library.
|
||||
Future<BaseItemDto> getItemById(String itemId) async {
|
||||
var response = await jellyfinApi.getItemById(
|
||||
userId: _finampUserHelper.currentUser!.id,
|
||||
itemId: itemId,
|
||||
);
|
||||
|
||||
return (BaseItemDto.fromJson(response));
|
||||
}
|
||||
|
||||
/// Creates a new playlist.
|
||||
Future<NewPlaylistResponse> createNewPlaylist(NewPlaylist newPlaylist) async {
|
||||
var response = await jellyfinApi.createNewPlaylist(
|
||||
newPlaylist: newPlaylist,
|
||||
);
|
||||
|
||||
return NewPlaylistResponse.fromJson(response);
|
||||
}
|
||||
|
||||
/// Adds items to a playlist.
|
||||
Future<void> addItemstoPlaylist({
|
||||
/// The playlist id.
|
||||
required String playlistId,
|
||||
|
||||
/// Item ids to add.
|
||||
List<String>? ids,
|
||||
}) async {
|
||||
await jellyfinApi.addItemsToPlaylist(
|
||||
playlistId: playlistId,
|
||||
ids: ids?.join(","),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove items to a playlist.
|
||||
Future<void> removeItemsFromPlaylist({
|
||||
/// The playlist id.
|
||||
required String playlistId,
|
||||
|
||||
/// Item ids to add.
|
||||
List<String>? entryIds,
|
||||
}) async {
|
||||
await jellyfinApi.removeItemsFromPlaylist(
|
||||
playlistId: playlistId,
|
||||
entryIds: entryIds?.join(","),
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates an item.
|
||||
Future<void> updateItem({
|
||||
/// The item id.
|
||||
required String itemId,
|
||||
|
||||
/// What to update the item with. You should give a BaseItemDto with only
|
||||
/// changed values.
|
||||
required BaseItemDto newItem,
|
||||
}) async {
|
||||
await jellyfinApi.updateItem(itemId: itemId, newItem: newItem);
|
||||
}
|
||||
|
||||
/// Marks an item as a favorite.
|
||||
Future<UserItemDataDto> addFavourite(String itemId) async {
|
||||
var response = await jellyfinApi.addFavourite(
|
||||
userId: _finampUserHelper.currentUser!.id, itemId: itemId);
|
||||
|
||||
return UserItemDataDto.fromJson(response);
|
||||
}
|
||||
|
||||
/// Unmarks item as a favorite.
|
||||
Future<UserItemDataDto> removeFavourite(String itemId) async {
|
||||
var response = await jellyfinApi.removeFavourite(
|
||||
userId: _finampUserHelper.currentUser!.id, itemId: itemId);
|
||||
|
||||
return UserItemDataDto.fromJson(response);
|
||||
}
|
||||
|
||||
void addArtistToMixBuilderList(BaseItemDto item) {
|
||||
selectedMixArtistsIds.add(item.id);
|
||||
}
|
||||
|
||||
void removeArtistFromBuilderList(BaseItemDto item) {
|
||||
selectedMixArtistsIds.remove(item.id);
|
||||
}
|
||||
|
||||
void addAlbumToMixBuilderList(BaseItemDto item) {
|
||||
selectedMixAlbumIds.add(item.id);
|
||||
}
|
||||
|
||||
void removeAlbumFromBuilderList(BaseItemDto item) {
|
||||
selectedMixAlbumIds.remove(item.id);
|
||||
}
|
||||
|
||||
Future<List<BaseItemDto>?> getArtistMix(List<String> artistIds) async {
|
||||
var response = await jellyfinApi.getItems(
|
||||
userId: _finampUserHelper.currentUser!.id,
|
||||
artistIds: artistIds.join(","),
|
||||
filters: "IsNotFolder",
|
||||
recursive: true,
|
||||
sortBy: "Random",
|
||||
limit: 300,
|
||||
fields: "Chapters");
|
||||
|
||||
return (QueryResult_BaseItemDto.fromJson(response).items);
|
||||
}
|
||||
|
||||
Future<List<BaseItemDto>?> getAlbumMix(List<String> albumIds) async {
|
||||
var response = await jellyfinApi.getItems(
|
||||
userId: _finampUserHelper.currentUser!.id,
|
||||
albumIds: albumIds.join(","),
|
||||
filters: "IsNotFolder",
|
||||
recursive: true,
|
||||
sortBy: "Random",
|
||||
limit: 300,
|
||||
fields: "Chapters");
|
||||
|
||||
return (QueryResult_BaseItemDto.fromJson(response).items);
|
||||
}
|
||||
|
||||
/// Removes the current user from the DB and revokes the token on Jellyfin
|
||||
Future<void> logoutCurrentUser() async {
|
||||
// We put this in a try-catch loop that basically ignores errors so that the
|
||||
// user can still log out during scenarios like wrong IP, no internet etc.
|
||||
|
||||
try {
|
||||
await jellyfinApi.logout().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => _jellyfinApiHelperLogger.warning(
|
||||
"Logout request timed out. Logging out anyway, but be aware that Jellyfin may have not got the signal."),
|
||||
);
|
||||
} catch (e) {
|
||||
_jellyfinApiHelperLogger.warning(
|
||||
"Jellyfin logout failed. Logging out anyway, but be aware that Jellyfin may have not got the signal.",
|
||||
e);
|
||||
} finally {
|
||||
// If we're unauthorised, the logout command will fail but we're already
|
||||
// basically logged out so we shouldn't fail.
|
||||
_finampUserHelper.removeUser(_finampUserHelper.currentUser!.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the correct image URL for the given item, or null if there is no
|
||||
/// image. Uses [getImageId] to get the actual id. [maxWidth] and [maxHeight]
|
||||
/// can be specified to return a smaller image. [quality] can be modified to
|
||||
/// get a higher/lower quality image.
|
||||
Uri? getImageUrl({
|
||||
required BaseItemDto item,
|
||||
int? maxWidth,
|
||||
int? maxHeight,
|
||||
int? quality = 90,
|
||||
String? format = "jpg",
|
||||
}) {
|
||||
if (item.imageId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsedBaseUrl = Uri.parse(_finampUserHelper.currentUser!.baseUrl);
|
||||
List<String> builtPath = List<String>.from(parsedBaseUrl.pathSegments);
|
||||
builtPath.addAll([
|
||||
"Items",
|
||||
item.imageId!,
|
||||
"Images",
|
||||
"Primary",
|
||||
]);
|
||||
return Uri(
|
||||
host: parsedBaseUrl.host,
|
||||
port: parsedBaseUrl.port,
|
||||
scheme: parsedBaseUrl.scheme,
|
||||
userInfo: parsedBaseUrl.userInfo,
|
||||
pathSegments: builtPath,
|
||||
queryParameters: {
|
||||
if (format != null) "format": format,
|
||||
if (quality != null) "quality": quality.toString(),
|
||||
if (maxWidth != null) "MaxWidth": maxWidth.toString(),
|
||||
if (maxHeight != null) "MaxHeight": maxHeight.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
class LocaleHelper {
|
||||
static const boxName = "Locale";
|
||||
|
||||
static ValueListenable<Box<Locale?>> get localeListener =>
|
||||
Hive.box<Locale?>(boxName).listenable();
|
||||
|
||||
static Locale? get locale => Hive.box<Locale?>(boxName).get(boxName);
|
||||
|
||||
static void setLocale(Locale? locale) {
|
||||
Hive.box<Locale?>(boxName).put(boxName, locale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import 'music_player_background_task.dart';
|
||||
|
||||
class MediaState {
|
||||
final MediaItem? mediaItem;
|
||||
final PlaybackState playbackState;
|
||||
|
||||
MediaState(this.mediaItem, this.playbackState);
|
||||
}
|
||||
|
||||
/// A stream reporting the combined state of the current media item and its
|
||||
/// current position.
|
||||
Stream<MediaState> get mediaStateStream {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
return Rx.combineLatest2<MediaItem?, PlaybackState, MediaState>(
|
||||
audioHandler.mediaItem,
|
||||
audioHandler.playbackState,
|
||||
(mediaItem, playbackState) => MediaState(mediaItem, playbackState));
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:android_id/android_id.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:finamp/services/offline_listen_helper.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../models/finamp_models.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
import 'finamp_settings_helper.dart';
|
||||
import 'finamp_user_helper.dart';
|
||||
import 'jellyfin_api_helper.dart';
|
||||
|
||||
// Largely copied from just_audio's DefaultShuffleOrder, but with a mildly
|
||||
// stupid hack to insert() to make Play Next work
|
||||
class FinampShuffleOrder extends ShuffleOrder {
|
||||
final Random _random;
|
||||
@override
|
||||
final indices = <int>[];
|
||||
|
||||
FinampShuffleOrder({Random? random}) : _random = random ?? Random();
|
||||
|
||||
@override
|
||||
void shuffle({int? initialIndex}) {
|
||||
assert(initialIndex == null || indices.contains(initialIndex));
|
||||
if (indices.length <= 1) return;
|
||||
indices.shuffle(_random);
|
||||
if (initialIndex == null) return;
|
||||
|
||||
const initialPos = 0;
|
||||
final swapPos = indices.indexOf(initialIndex);
|
||||
// Swap the indices at initialPos and swapPos.
|
||||
final swapIndex = indices[initialPos];
|
||||
indices[initialPos] = initialIndex;
|
||||
indices[swapPos] = swapIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
void insert(int index, int count) {
|
||||
// Offset indices after insertion point.
|
||||
for (var i = 0; i < indices.length; i++) {
|
||||
if (indices[i] >= index) {
|
||||
indices[i] += count;
|
||||
}
|
||||
}
|
||||
|
||||
final newIndices = List.generate(count, (i) => index + i);
|
||||
// This is the only modification from DefaultShuffleOrder: Only shuffle
|
||||
// inserted indices amongst themselves, but keep them contiguous
|
||||
newIndices.shuffle(_random);
|
||||
indices.insertAll(index, newIndices);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeRange(int start, int end) {
|
||||
final count = end - start;
|
||||
// Remove old indices.
|
||||
final oldIndices = List.generate(count, (i) => start + i).toSet();
|
||||
indices.removeWhere(oldIndices.contains);
|
||||
// Offset indices after deletion point.
|
||||
for (var i = 0; i < indices.length; i++) {
|
||||
if (indices[i] >= end) {
|
||||
indices[i] -= count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
indices.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// This provider handles the currently playing music so that multiple widgets
|
||||
/// can control music.
|
||||
class MusicPlayerBackgroundTask extends BaseAudioHandler {
|
||||
final _player = AudioPlayer(
|
||||
audioLoadConfiguration: AudioLoadConfiguration(
|
||||
androidLoadControl: AndroidLoadControl(
|
||||
minBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration,
|
||||
maxBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration,
|
||||
prioritizeTimeOverSizeThresholds: true,
|
||||
),
|
||||
darwinLoadControl: DarwinLoadControl(
|
||||
preferredForwardBufferDuration:
|
||||
FinampSettingsHelper.finampSettings.bufferDuration,
|
||||
)),
|
||||
);
|
||||
ConcatenatingAudioSource _queueAudioSource = ConcatenatingAudioSource(
|
||||
children: [],
|
||||
shuffleOrder: FinampShuffleOrder(),
|
||||
);
|
||||
final _audioServiceBackgroundTaskLogger = Logger("MusicPlayerBackgroundTask");
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _offlineListenLogHelper = GetIt.instance<OfflineListenLogHelper>();
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
/// Set when shuffle mode is changed. If true, [onUpdateQueue] will create a
|
||||
/// shuffled [ConcatenatingAudioSource].
|
||||
bool shuffleNextQueue = false;
|
||||
|
||||
/// Set when creating a new queue. Will be used to set the first index in a
|
||||
/// new queue.
|
||||
int? nextInitialIndex;
|
||||
|
||||
/// Set to true when we're stopping the audio service. Used to avoid playback
|
||||
/// progress reporting.
|
||||
bool _isStopping = false;
|
||||
|
||||
/// Holds the current sleep timer, if any. This is a ValueNotifier so that
|
||||
/// widgets like SleepTimerButton can update when the sleep timer is/isn't
|
||||
/// null.
|
||||
bool _sleepTimerIsSet = false;
|
||||
Duration _sleepTimerDuration = Duration.zero;
|
||||
final ValueNotifier<Timer?> _sleepTimer = ValueNotifier<Timer?>(null);
|
||||
|
||||
List<int>? get shuffleIndices => _player.shuffleIndices;
|
||||
|
||||
ValueListenable<Timer?> get sleepTimer => _sleepTimer;
|
||||
|
||||
MusicPlayerBackgroundTask() {
|
||||
_audioServiceBackgroundTaskLogger.info("Starting audio service");
|
||||
|
||||
// Propagate all events from the audio player to AudioService clients.
|
||||
_player.playbackEventStream.listen((event) async {
|
||||
final prevState = playbackState.valueOrNull;
|
||||
final prevIndex = prevState?.queueIndex;
|
||||
final prevItem = mediaItem.valueOrNull;
|
||||
final currentState = _transformEvent(event);
|
||||
final currentIndex = currentState.queueIndex;
|
||||
|
||||
playbackState.add(currentState);
|
||||
|
||||
if (currentIndex != null) {
|
||||
final currentItem = _getQueueItem(currentIndex);
|
||||
|
||||
// Differences in queue index or item id are considered track changes
|
||||
if (currentIndex != prevIndex || currentItem.id != prevItem?.id) {
|
||||
mediaItem.add(currentItem);
|
||||
|
||||
onTrackChanged(currentItem, currentState, prevItem, prevState);
|
||||
}
|
||||
}
|
||||
|
||||
if (playbackState.valueOrNull != null &&
|
||||
playbackState.valueOrNull?.processingState !=
|
||||
AudioProcessingState.idle &&
|
||||
playbackState.valueOrNull?.processingState !=
|
||||
AudioProcessingState.completed &&
|
||||
!FinampSettingsHelper.finampSettings.isOffline &&
|
||||
!_isStopping) {
|
||||
await _updatePlaybackProgress();
|
||||
}
|
||||
});
|
||||
|
||||
// Special processing for state transitions.
|
||||
_player.processingStateStream.listen((event) {
|
||||
if (event == ProcessingState.completed) {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
// PlaybackEvent doesn't include shuffle/loops so we listen for changes here
|
||||
_player.shuffleModeEnabledStream.listen(
|
||||
(_) => playbackState.add(_transformEvent(_player.playbackEvent)));
|
||||
_player.loopModeStream.listen(
|
||||
(_) => playbackState.add(_transformEvent(_player.playbackEvent)));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() {
|
||||
// If a sleep timer has been set and the timer went off
|
||||
// causing play to pause, if the user starts to play
|
||||
// audio again, and the sleep timer hasn't been explicitly
|
||||
// turned off, then reset the sleep timer.
|
||||
// This is useful if the sleep timer pauses play too early
|
||||
// and the user wants to continue listening
|
||||
if (_sleepTimerIsSet && _sleepTimer.value == null) {
|
||||
// restart the sleep timer for another period
|
||||
setSleepTimer(_sleepTimerDuration);
|
||||
}
|
||||
|
||||
return _player.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() => _player.pause();
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
try {
|
||||
_audioServiceBackgroundTaskLogger.info("Stopping audio service");
|
||||
|
||||
_isStopping = true;
|
||||
|
||||
// Tell Jellyfin we're no longer playing audio if we're online
|
||||
if (!FinampSettingsHelper.finampSettings.isOffline) {
|
||||
final playbackInfo = generateCurrentPlaybackProgressInfo();
|
||||
if (playbackInfo != null) {
|
||||
await _jellyfinApiHelper.stopPlaybackProgress(playbackInfo);
|
||||
}
|
||||
} else {
|
||||
final currentIndex = _player.currentIndex;
|
||||
if (_queueAudioSource.length != 0 && currentIndex != null) {
|
||||
final item = _getQueueItem(currentIndex);
|
||||
_offlineListenLogHelper.logOfflineListen(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop playing audio.
|
||||
await _player.stop();
|
||||
|
||||
// Seek to the start of the first item in the queue
|
||||
await _player.seek(Duration.zero, index: 0);
|
||||
|
||||
_sleepTimerIsSet = false;
|
||||
_sleepTimerDuration = Duration.zero;
|
||||
|
||||
_sleepTimer.value?.cancel();
|
||||
_sleepTimer.value = null;
|
||||
|
||||
await super.stop();
|
||||
|
||||
// await _player.dispose();
|
||||
// await _eventSubscription?.cancel();
|
||||
// // It is important to wait for this state to be broadcast before we shut
|
||||
// // down the task. If we don't, the background task will be destroyed before
|
||||
// // the message gets sent to the UI.
|
||||
// await _broadcastState();
|
||||
// // Shut down this background task
|
||||
// await super.stop();
|
||||
|
||||
_isStopping = false;
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@Deprecated("Use addQueueItems instead")
|
||||
Future<void> addQueueItem(MediaItem mediaItem) async {
|
||||
addQueueItems([mediaItem]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addQueueItems(List<MediaItem> mediaItems) async {
|
||||
try {
|
||||
final sources =
|
||||
await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i)));
|
||||
await _queueAudioSource.addAll(sources);
|
||||
queue.add(_queueFromSource());
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertQueueItemsNext(List<MediaItem> mediaItems) async {
|
||||
try {
|
||||
var idx = _player.currentIndex;
|
||||
if (idx != null) {
|
||||
if (_player.shuffleModeEnabled) {
|
||||
var next = _player.shuffleIndices?.indexOf(idx);
|
||||
idx = next == -1 || next == null ? null : next + 1;
|
||||
} else {
|
||||
++idx;
|
||||
}
|
||||
}
|
||||
idx ??= 0;
|
||||
|
||||
final sources =
|
||||
await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i)));
|
||||
await _queueAudioSource.insertAll(idx, sources);
|
||||
queue.add(_queueFromSource());
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateQueue(List<MediaItem> newQueue) async {
|
||||
try {
|
||||
// Convert the MediaItems to AudioSources
|
||||
List<AudioSource> audioSources = [];
|
||||
for (final mediaItem in newQueue) {
|
||||
audioSources.add(await _mediaItemToAudioSource(mediaItem));
|
||||
}
|
||||
|
||||
// Create a new ConcatenatingAudioSource with the new queue.
|
||||
_queueAudioSource = ConcatenatingAudioSource(
|
||||
children: audioSources,
|
||||
shuffleOrder: FinampShuffleOrder(),
|
||||
);
|
||||
|
||||
try {
|
||||
await _player.setAudioSource(
|
||||
_queueAudioSource,
|
||||
initialIndex: nextInitialIndex,
|
||||
);
|
||||
} on PlayerException catch (e) {
|
||||
_audioServiceBackgroundTaskLogger
|
||||
.severe("Player error code ${e.code}: ${e.message}");
|
||||
} on PlayerInterruptedException catch (e) {
|
||||
_audioServiceBackgroundTaskLogger
|
||||
.warning("Player interrupted: ${e.message}");
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger
|
||||
.severe("Player error ${e.toString()}");
|
||||
}
|
||||
queue.add(_queueFromSource());
|
||||
|
||||
// Sets the media item for the new queue. This will be whatever is
|
||||
// currently playing from the new queue (for example, the first song in
|
||||
// an album). If the player is shuffling, set the index to the player's
|
||||
// current index. Otherwise, set it to nextInitialIndex. nextInitialIndex
|
||||
// is much more stable than the current index as we know the value is set
|
||||
// when running this function.
|
||||
if (_player.shuffleModeEnabled) {
|
||||
if (_player.currentIndex == null) {
|
||||
_audioServiceBackgroundTaskLogger.severe(
|
||||
"_player.currentIndex is null during onUpdateQueue, not setting new media item");
|
||||
} else {
|
||||
mediaItem.add(_getQueueItem(_player.currentIndex!));
|
||||
}
|
||||
} else {
|
||||
if (nextInitialIndex == null) {
|
||||
_audioServiceBackgroundTaskLogger.severe(
|
||||
"nextInitialIndex is null during onUpdateQueue, not setting new media item");
|
||||
} else {
|
||||
mediaItem.add(_getQueueItem(nextInitialIndex!));
|
||||
}
|
||||
}
|
||||
|
||||
shuffleNextQueue = false;
|
||||
nextInitialIndex = null;
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToPrevious() async {
|
||||
try {
|
||||
if (!_player.hasPrevious || _player.position.inSeconds >= 5) {
|
||||
await _player.seek(Duration.zero, index: _player.currentIndex);
|
||||
} else {
|
||||
await _player.seek(Duration.zero, index: _player.previousIndex);
|
||||
}
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
try {
|
||||
await _player.seekToNext();
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> skipToIndex(int index) async {
|
||||
try {
|
||||
await _player.seek(Duration.zero, index: index);
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
try {
|
||||
await _player.seek(position);
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||
try {
|
||||
switch (shuffleMode) {
|
||||
case AudioServiceShuffleMode.all:
|
||||
await _player.setShuffleModeEnabled(true);
|
||||
shuffleNextQueue = true;
|
||||
break;
|
||||
case AudioServiceShuffleMode.none:
|
||||
await _player.setShuffleModeEnabled(false);
|
||||
shuffleNextQueue = false;
|
||||
break;
|
||||
default:
|
||||
return Future.error(
|
||||
"Unsupported AudioServiceRepeatMode! Recieved ${shuffleMode.toString()}, requires all or none.");
|
||||
}
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
||||
try {
|
||||
switch (repeatMode) {
|
||||
case AudioServiceRepeatMode.all:
|
||||
await _player.setLoopMode(LoopMode.all);
|
||||
break;
|
||||
case AudioServiceRepeatMode.none:
|
||||
await _player.setLoopMode(LoopMode.off);
|
||||
break;
|
||||
case AudioServiceRepeatMode.one:
|
||||
await _player.setLoopMode(LoopMode.one);
|
||||
break;
|
||||
default:
|
||||
return Future.error(
|
||||
"Unsupported AudioServiceRepeatMode! Received ${repeatMode.toString()}, requires all, none, or one.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeQueueItemAt(int index) async {
|
||||
try {
|
||||
await _queueAudioSource.removeAt(index);
|
||||
queue.add(_queueFromSource());
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Report track changes to the Jellyfin Server if the user is not offline.
|
||||
Future<void> onTrackChanged(
|
||||
MediaItem currentItem,
|
||||
PlaybackState currentState,
|
||||
MediaItem? previousItem,
|
||||
PlaybackState? previousState,
|
||||
) async {
|
||||
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
|
||||
|
||||
if (previousItem != null &&
|
||||
previousState != null &&
|
||||
// don't submit stop events for idle tracks (at position 0 and not playing)
|
||||
(previousState.playing ||
|
||||
previousState.updatePosition != Duration.zero)) {
|
||||
if (!isOffline) {
|
||||
final playbackData = generatePlaybackProgressInfoFromState(
|
||||
previousItem,
|
||||
previousState,
|
||||
);
|
||||
|
||||
if (playbackData != null) {
|
||||
try {
|
||||
await _jellyfinApiHelper.stopPlaybackProgress(playbackData);
|
||||
} catch (e) {
|
||||
_offlineListenLogHelper.logOfflineListen(previousItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_offlineListenLogHelper.logOfflineListen(previousItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOffline) {
|
||||
final playbackData = generatePlaybackProgressInfoFromState(
|
||||
currentItem,
|
||||
currentState,
|
||||
);
|
||||
|
||||
if (playbackData != null) {
|
||||
await _jellyfinApiHelper.reportPlaybackStart(playbackData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates PlaybackProgressInfo for the supplied item and player info.
|
||||
PlaybackProgressInfo? generatePlaybackProgressInfo(
|
||||
MediaItem item, {
|
||||
required bool isPaused,
|
||||
required bool isMuted,
|
||||
required Duration playerPosition,
|
||||
required String repeatMode,
|
||||
required bool includeNowPlayingQueue,
|
||||
}) {
|
||||
try {
|
||||
return PlaybackProgressInfo(
|
||||
itemId: item.extras!["itemJson"]["Id"],
|
||||
isPaused: isPaused,
|
||||
isMuted: isMuted,
|
||||
positionTicks: playerPosition.inMicroseconds * 10,
|
||||
repeatMode: repeatMode,
|
||||
playMethod: item.extras!["shouldTranscode"] ?? false
|
||||
? "Transcode"
|
||||
: "DirectPlay",
|
||||
// We don't send the queue since it seems useless and it can cause
|
||||
// issues with large queues.
|
||||
// https://github.com/jmshrv/finamp/issues/387
|
||||
|
||||
// nowPlayingQueue: includeNowPlayingQueue
|
||||
// ? _queueFromSource()
|
||||
// .map(
|
||||
// (e) => QueueItem(
|
||||
// id: e.extras!["itemJson"]["Id"], playlistItemId: e.id),
|
||||
// )
|
||||
// .toList()
|
||||
// : null,
|
||||
);
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates PlaybackProgressInfo from current player info.
|
||||
/// Returns null if _queue is empty.
|
||||
/// If an item is not supplied, the current queue index will be used.
|
||||
PlaybackProgressInfo? generateCurrentPlaybackProgressInfo() {
|
||||
final currentIndex = _player.currentIndex;
|
||||
if (_queueAudioSource.length == 0 || currentIndex == null) {
|
||||
// This function relies on _queue having items,
|
||||
// so we return null if it's empty or no index is played
|
||||
// and no custom item was passed to avoid more errors.
|
||||
return null;
|
||||
}
|
||||
final item = _getQueueItem(currentIndex);
|
||||
|
||||
return generatePlaybackProgressInfo(
|
||||
item,
|
||||
isPaused: !_player.playing,
|
||||
isMuted: _player.volume == 0,
|
||||
playerPosition: _player.position,
|
||||
repeatMode: _jellyfinRepeatModeFromLoopMode(_player.loopMode),
|
||||
includeNowPlayingQueue: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generates PlaybackProgressInfo for the supplied item and playback state.
|
||||
PlaybackProgressInfo? generatePlaybackProgressInfoFromState(
|
||||
MediaItem item,
|
||||
PlaybackState state,
|
||||
) {
|
||||
final duration = item.duration;
|
||||
return generatePlaybackProgressInfo(
|
||||
item,
|
||||
isPaused: !state.playing,
|
||||
// always consider as unmuted
|
||||
isMuted: false,
|
||||
// ensure the (extrapolated) position doesn't exceed the duration
|
||||
playerPosition: duration != null && state.position > duration
|
||||
? duration
|
||||
: state.position,
|
||||
repeatMode: _jellyfinRepeatModeFromRepeatMode(state.repeatMode),
|
||||
includeNowPlayingQueue: true,
|
||||
);
|
||||
}
|
||||
|
||||
void setNextInitialIndex(int index) {
|
||||
nextInitialIndex = index;
|
||||
}
|
||||
|
||||
Future<void> reorderQueue(int oldIndex, int newIndex) async {
|
||||
// When we're moving an item forwards, we need to reduce newIndex by 1
|
||||
// to account for the current item being removed before re-insertion.
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
await _queueAudioSource.move(oldIndex, newIndex);
|
||||
queue.add(_queueFromSource());
|
||||
_audioServiceBackgroundTaskLogger.log(Level.INFO, "Published queue");
|
||||
}
|
||||
|
||||
/// Sets the sleep timer with the given [duration].
|
||||
Timer setSleepTimer(Duration duration) {
|
||||
_sleepTimerIsSet = true;
|
||||
_sleepTimerDuration = duration;
|
||||
|
||||
_sleepTimer.value = Timer(duration, () async {
|
||||
_sleepTimer.value = null;
|
||||
return await pause();
|
||||
});
|
||||
return _sleepTimer.value!;
|
||||
}
|
||||
|
||||
/// Cancels the sleep timer and clears it.
|
||||
void clearSleepTimer() {
|
||||
_sleepTimerIsSet = false;
|
||||
_sleepTimerDuration = Duration.zero;
|
||||
|
||||
_sleepTimer.value?.cancel();
|
||||
_sleepTimer.value = null;
|
||||
}
|
||||
|
||||
/// Transform a just_audio event into an audio_service state.
|
||||
///
|
||||
/// This method is used from the constructor. Every event received from the
|
||||
/// just_audio player will be transformed into an audio_service state so that
|
||||
/// it can be broadcast to audio_service clients.
|
||||
PlaybackState _transformEvent(PlaybackEvent event) {
|
||||
return PlaybackState(
|
||||
controls: [
|
||||
MediaControl.skipToPrevious,
|
||||
if (_player.playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
MediaAction.seekForward,
|
||||
MediaAction.seekBackward,
|
||||
},
|
||||
androidCompactActionIndices: const [0, 1, 3],
|
||||
processingState: const {
|
||||
ProcessingState.idle: AudioProcessingState.idle,
|
||||
ProcessingState.loading: AudioProcessingState.loading,
|
||||
ProcessingState.buffering: AudioProcessingState.buffering,
|
||||
ProcessingState.ready: AudioProcessingState.ready,
|
||||
ProcessingState.completed: AudioProcessingState.completed,
|
||||
}[_player.processingState]!,
|
||||
playing: _player.playing,
|
||||
updatePosition: _player.position,
|
||||
bufferedPosition: _player.bufferedPosition,
|
||||
speed: _player.speed,
|
||||
queueIndex: event.currentIndex,
|
||||
shuffleMode: _player.shuffleModeEnabled
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none,
|
||||
repeatMode: _audioServiceRepeatMode(_player.loopMode),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updatePlaybackProgress() async {
|
||||
try {
|
||||
JellyfinApiHelper jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
final playbackInfo = generateCurrentPlaybackProgressInfo();
|
||||
if (playbackInfo != null) {
|
||||
await jellyfinApiHelper.updatePlaybackProgress(playbackInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
MediaItem _getQueueItem(int index) {
|
||||
return _queueAudioSource.sequence[index].tag as MediaItem;
|
||||
}
|
||||
|
||||
List<MediaItem> _queueFromSource() {
|
||||
return _queueAudioSource.sequence.map((e) => e.tag as MediaItem).toList();
|
||||
}
|
||||
|
||||
/// Syncs the list of MediaItems (_queue) with the internal queue of the player.
|
||||
/// Called by onAddQueueItem and onUpdateQueue.
|
||||
Future<AudioSource> _mediaItemToAudioSource(MediaItem mediaItem) async {
|
||||
if (mediaItem.extras!["downloadedSongJson"] == null) {
|
||||
// If DownloadedSong wasn't passed, we assume that the item is not
|
||||
// downloaded.
|
||||
|
||||
// If offline, we throw an error so that we don't accidentally stream from
|
||||
// the internet. See the big comment in _songUri() to see why this was
|
||||
// passed in extras.
|
||||
if (mediaItem.extras!["isOffline"]) {
|
||||
return Future.error(
|
||||
"Offline mode enabled but downloaded song not found.");
|
||||
} else {
|
||||
if (mediaItem.extras!["shouldTranscode"] == true) {
|
||||
return HlsAudioSource(await _songUri(mediaItem), tag: mediaItem);
|
||||
} else {
|
||||
return AudioSource.uri(await _songUri(mediaItem), tag: mediaItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We have to deserialize this because Dart is stupid and can't handle
|
||||
// sending classes through isolates.
|
||||
final downloadedSong =
|
||||
DownloadedSong.fromJson(mediaItem.extras!["downloadedSongJson"]);
|
||||
|
||||
// Path verification and stuff is done in AudioServiceHelper, so this path
|
||||
// should be valid.
|
||||
final downloadUri = Uri.file(downloadedSong.file.path);
|
||||
return AudioSource.uri(downloadUri, tag: mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uri> _songUri(MediaItem mediaItem) async {
|
||||
// We need the platform to be Android or iOS to get device info
|
||||
assert(Platform.isAndroid || Platform.isIOS,
|
||||
"_songUri() only supports Android and iOS");
|
||||
|
||||
// When creating the MediaItem (usually in AudioServiceHelper), we specify
|
||||
// whether or not to transcode. We used to pull from FinampSettings here,
|
||||
// but since audio_service runs in an isolate (or at least, it does until
|
||||
// 0.18), the value would be wrong if changed while a song was playing since
|
||||
// Hive is bad at multi-isolate stuff.
|
||||
|
||||
final androidId =
|
||||
Platform.isAndroid ? await const AndroidId().getId() : null;
|
||||
final iosDeviceInfo =
|
||||
Platform.isIOS ? await DeviceInfoPlugin().iosInfo : null;
|
||||
|
||||
final parsedBaseUrl = Uri.parse(_finampUserHelper.currentUser!.baseUrl);
|
||||
|
||||
List<String> builtPath = List.from(parsedBaseUrl.pathSegments);
|
||||
|
||||
Map<String, String> queryParameters =
|
||||
Map.from(parsedBaseUrl.queryParameters);
|
||||
|
||||
// We include the user token as a query parameter because just_audio used to
|
||||
// have issues with headers in HLS, and this solution still works fine
|
||||
queryParameters["ApiKey"] = _finampUserHelper.currentUser!.accessToken;
|
||||
|
||||
if (mediaItem.extras!["shouldTranscode"]) {
|
||||
builtPath.addAll([
|
||||
"Audio",
|
||||
mediaItem.extras!["itemJson"]["Id"],
|
||||
"main.m3u8",
|
||||
]);
|
||||
|
||||
queryParameters.addAll({
|
||||
"audioCodec": "aac",
|
||||
// Ideally we'd use 48kHz when the source is, realistically it doesn't
|
||||
// matter too much
|
||||
"audioSampleRate": "44100",
|
||||
"maxAudioBitDepth": "16",
|
||||
"audioBitRate":
|
||||
FinampSettingsHelper.finampSettings.transcodeBitrate.toString(),
|
||||
});
|
||||
} else {
|
||||
builtPath.addAll([
|
||||
"Items",
|
||||
mediaItem.extras!["itemJson"]["Id"],
|
||||
"File",
|
||||
]);
|
||||
}
|
||||
|
||||
return Uri(
|
||||
host: parsedBaseUrl.host,
|
||||
port: parsedBaseUrl.port,
|
||||
scheme: parsedBaseUrl.scheme,
|
||||
userInfo: parsedBaseUrl.userInfo,
|
||||
pathSegments: builtPath,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AudioServiceRepeatMode _audioServiceRepeatMode(LoopMode loopMode) {
|
||||
switch (loopMode) {
|
||||
case LoopMode.off:
|
||||
return AudioServiceRepeatMode.none;
|
||||
case LoopMode.one:
|
||||
return AudioServiceRepeatMode.one;
|
||||
case LoopMode.all:
|
||||
return AudioServiceRepeatMode.all;
|
||||
}
|
||||
}
|
||||
|
||||
String _jellyfinRepeatModeFromLoopMode(LoopMode loopMode) {
|
||||
switch (loopMode) {
|
||||
case LoopMode.off:
|
||||
return "RepeatNone";
|
||||
case LoopMode.one:
|
||||
return "RepeatOne";
|
||||
case LoopMode.all:
|
||||
return "RepeatAll";
|
||||
}
|
||||
}
|
||||
|
||||
String _jellyfinRepeatModeFromRepeatMode(AudioServiceRepeatMode repeatMode) {
|
||||
switch (repeatMode) {
|
||||
case AudioServiceRepeatMode.none:
|
||||
return "RepeatNone";
|
||||
case AudioServiceRepeatMode.one:
|
||||
return "RepeatOne";
|
||||
case AudioServiceRepeatMode.all:
|
||||
case AudioServiceRepeatMode.group:
|
||||
return "RepeatAll";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:finamp/models/finamp_models.dart';
|
||||
import 'package:finamp/services/finamp_user_helper.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Logs offline listens or failed submissions to a file.
|
||||
class OfflineListenLogHelper {
|
||||
final _logger = Logger("OfflineListenLogHelper");
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
Future<Directory> get _logDirectory async {
|
||||
if (!Platform.isAndroid) {
|
||||
return await getApplicationDocumentsDirectory();
|
||||
}
|
||||
|
||||
final List<Directory>? dirs =
|
||||
await getExternalStorageDirectories(type: StorageDirectory.documents);
|
||||
return dirs?.first ?? await getApplicationDocumentsDirectory();
|
||||
}
|
||||
|
||||
Future<File> get _logFile async {
|
||||
final Directory directory = await _logDirectory;
|
||||
return File("${directory.path}/listens.json");
|
||||
}
|
||||
|
||||
/// Logs a listen to a file.
|
||||
///
|
||||
/// This is used when the user is offline or submitting live playback events fails.
|
||||
Future<void> logOfflineListen(MediaItem item) async {
|
||||
final itemJson = item.extras!["itemJson"];
|
||||
|
||||
final offlineListen = OfflineListen(
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
userId: _finampUserHelper.currentUserId!,
|
||||
itemId: itemJson["Id"],
|
||||
name: itemJson["Name"],
|
||||
artist: itemJson["AlbumArtist"],
|
||||
album: itemJson["Album"],
|
||||
trackMbid: itemJson["ProviderIds"]?["MusicBrainzTrack"],
|
||||
);
|
||||
|
||||
await _logOfflineListen(offlineListen);
|
||||
}
|
||||
|
||||
/// Logs a listen to a file.
|
||||
///
|
||||
/// This is used when the user is offline or submitting live playback events fails.
|
||||
/// The [timestamp] provided to this function should be in seconds
|
||||
/// and marks the time the track was stopped.
|
||||
Future<void> _logOfflineListen(OfflineListen listen) async {
|
||||
Hive.box<OfflineListen>("OfflineListens").add(listen);
|
||||
|
||||
_exportOfflineListenToFile(listen);
|
||||
}
|
||||
|
||||
Future<void> _exportOfflineListenToFile(OfflineListen listen) async {
|
||||
final data = {
|
||||
'timestamp': listen.timestamp,
|
||||
'item_id': listen.itemId,
|
||||
'title': listen.name,
|
||||
'artist': listen.artist,
|
||||
'album': listen.album,
|
||||
'track_mbid': listen.trackMbid,
|
||||
'user_id': listen.userId,
|
||||
};
|
||||
final content = json.encode(data) + Platform.lineTerminator;
|
||||
|
||||
final file = await _logFile;
|
||||
try {
|
||||
file.writeAsString(content, mode: FileMode.append, flush: true);
|
||||
} catch (e) {
|
||||
_logger.warning("Failed to write listen to file: $content");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
String processArtist(String? artist, BuildContext context) {
|
||||
if (artist == null) {
|
||||
return AppLocalizations.of(context)!.unknownArtist;
|
||||
} else {
|
||||
return artist;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import 'music_player_background_task.dart';
|
||||
|
||||
class ProgressState {
|
||||
final MediaItem? mediaItem;
|
||||
final PlaybackState playbackState;
|
||||
final Duration position;
|
||||
|
||||
ProgressState(this.mediaItem, this.playbackState, this.position);
|
||||
}
|
||||
|
||||
/// Encapsulate all the different data we're interested in into a single
|
||||
/// stream so we don't have to nest StreamBuilders.
|
||||
Stream<ProgressState> get progressStateStream {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
return Rx.combineLatest3<MediaItem?, PlaybackState, Duration, ProgressState>(
|
||||
audioHandler.mediaItem,
|
||||
audioHandler.playbackState,
|
||||
AudioService.position.startWith(audioHandler.playbackState.value.position),
|
||||
(mediaItem, playbackState, position) =>
|
||||
ProgressState(mediaItem, playbackState, position));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:finamp/components/AlbumScreen/download_dialog.dart';
|
||||
import 'package:finamp/services/finamp_settings_helper.dart';
|
||||
import 'package:finamp/services/finamp_user_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:finamp/services/downloads_helper.dart';
|
||||
import 'package:finamp/services/jellyfin_api_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../models/finamp_models.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
|
||||
class SyncState {
|
||||
final Set<String> toAdd;
|
||||
final Set<String> toRemove;
|
||||
final Set<String> toUpdate;
|
||||
final List<BaseItemDto> addItemCache;
|
||||
|
||||
SyncState(this.toAdd, this.toRemove, this.toUpdate, this.addItemCache);
|
||||
}
|
||||
|
||||
class DownloadsSyncHelper {
|
||||
final DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
final JellyfinApiHelper jellyfinApiHelper =
|
||||
GetIt.instance<JellyfinApiHelper>();
|
||||
final FinampUserHelper _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
final Logger logger;
|
||||
|
||||
DownloadsSyncHelper(this.logger);
|
||||
|
||||
void sync(BuildContext context, BaseItemDto parentObject,
|
||||
List<BaseItemDto> items) async {
|
||||
var syncState = _getSyncState(parentObject.id, items);
|
||||
|
||||
logger.info("Items to be added ${syncState.toAdd}");
|
||||
logger.info("Items to be removed ${syncState.toRemove}");
|
||||
logger.info("Items with no change ${syncState.toUpdate}");
|
||||
|
||||
await _removeDownloadedItems(parentObject.id, syncState.toRemove);
|
||||
await _downloadItems(context, parentObject, syncState);
|
||||
}
|
||||
|
||||
Future<void> _downloadItems(BuildContext context, BaseItemDto parentObject,
|
||||
SyncState syncState) async {
|
||||
final List<BaseItemDto> itemsToDownload = [];
|
||||
|
||||
// we include update items in case any items have been orphaned
|
||||
for (String itemToAdd in {...syncState.toAdd, ...syncState.toUpdate}) {
|
||||
final cacheIndex = syncState.addItemCache
|
||||
.indexWhere((element) => element.id == itemToAdd);
|
||||
BaseItemDto? item;
|
||||
if (cacheIndex != -1) {
|
||||
item = syncState.addItemCache.removeAt(cacheIndex);
|
||||
}
|
||||
if (item != null) {
|
||||
itemsToDownload.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
final selectedDownloadLocation = FinampSettingsHelper
|
||||
.finampSettings.downloadLocationsMap.values.length >
|
||||
1
|
||||
? await showDialog<DownloadLocation>(
|
||||
context: context,
|
||||
builder: (context) => DownloadDialog(
|
||||
parents: [parentObject],
|
||||
// getItems returns null so we have to null check
|
||||
// each element
|
||||
items: [itemsToDownload],
|
||||
viewId: _finampUserHelper.currentUser!.currentViewId!,
|
||||
),
|
||||
)
|
||||
: FinampSettingsHelper.finampSettings.downloadLocationsMap.values.first;
|
||||
|
||||
if (itemsToDownload.isNotEmpty && selectedDownloadLocation != null) {
|
||||
await downloadsHelper.addDownloads(
|
||||
items: itemsToDownload,
|
||||
parent: parentObject,
|
||||
useHumanReadableNames: selectedDownloadLocation.useHumanReadableNames,
|
||||
downloadLocation: selectedDownloadLocation,
|
||||
viewId: _finampUserHelper.currentUser!.currentViewId!,
|
||||
);
|
||||
} else {
|
||||
logger.warning("No items to download for parent: ${parentObject.id}");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeDownloadedItems(
|
||||
String playlistParentId, Set<String> idsToRemove) async {
|
||||
var itemsToRemove = idsToRemove.toList();
|
||||
await downloadsHelper.deleteDownloadChildren(
|
||||
jellyfinItemIds: itemsToRemove,
|
||||
deletedFor: itemsToRemove.isEmpty ? null : playlistParentId);
|
||||
|
||||
final downloadParent =
|
||||
downloadsHelper.getDownloadedParent(playlistParentId);
|
||||
if (downloadParent != null && downloadParent.downloadedChildren.isEmpty) {
|
||||
// only remove parent if there are no children left
|
||||
await downloadsHelper.deleteDownloadParent(deletedFor: playlistParentId);
|
||||
}
|
||||
await downloadsHelper.removeChildFromParent(
|
||||
parentId: playlistParentId, childIds: idsToRemove.toList());
|
||||
}
|
||||
|
||||
SyncState _getSyncState(
|
||||
String playlistParentId, List<BaseItemDto> existingPlaylistItems) {
|
||||
List<DownloadedSong> downloadedSongs =
|
||||
downloadsHelper.downloadedItems.toList();
|
||||
List<DownloadedSong> downloadedSongsCache = [];
|
||||
List<BaseItemDto> playlistItems = [];
|
||||
for (BaseItemDto item in existingPlaylistItems) {
|
||||
// songs actively in playlist
|
||||
logger.info("Song in playlist id ${item.id} name ${item.name}");
|
||||
// playlistItems.putIfAbsent(item.id, () => item);
|
||||
if (playlistItems.indexWhere((element) => element.id == item.id) == -1) {
|
||||
playlistItems.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (DownloadedSong downloadedSong in downloadedSongs) {
|
||||
if (downloadedSong.mediaSourceInfo.id != null &&
|
||||
downloadedSong.requiredBy.contains(playlistParentId)) {
|
||||
// songs actively downloaded
|
||||
logger.info(
|
||||
"Downloaded song playlist id ${downloadedSong.mediaSourceInfo.id} name ${downloadedSong.mediaSourceInfo.name} requiredBy ${downloadedSong.requiredBy.toString()}");
|
||||
// downloadedSongsCache.putIfAbsent(downloadedSong.mediaSourceInfo.id!, () => downloadedSong);
|
||||
if (downloadedSongsCache.indexWhere((element) =>
|
||||
element.mediaSourceInfo.id ==
|
||||
downloadedSong.mediaSourceInfo.id) ==
|
||||
-1) {
|
||||
downloadedSongsCache.add(downloadedSong);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> playlistIds = playlistItems.map((e) => e.id).toSet();
|
||||
Set<String> downloadedIds =
|
||||
downloadedSongsCache.map((e) => e.mediaSourceInfo.id!).toSet();
|
||||
return SyncState(
|
||||
playlistIds.difference(downloadedIds),
|
||||
downloadedIds.difference(playlistIds),
|
||||
playlistIds.intersection(downloadedIds),
|
||||
playlistItems);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
/// A helper for setting the theme, like FinampSettingsHelper. We don't do theme
|
||||
/// stuff in FinampSettingsHelper as we would have to rebuild the MaterialApp on
|
||||
/// every setting change.
|
||||
class ThemeModeHelper {
|
||||
static ValueListenable<Box<ThemeMode>> get themeModeListener =>
|
||||
Hive.box<ThemeMode>("ThemeMode").listenable(keys: ["ThemeMode"]);
|
||||
|
||||
static void setThemeMode(ThemeMode themeMode) {
|
||||
Hive.box<ThemeMode>("ThemeMode").put("ThemeMode", themeMode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user