first commit
Build / Build for Android (push) Has been cancelled
Build / Build for iOS (push) Has been cancelled

This commit is contained in:
Zakaria
2026-05-18 14:15:38 -04:00
commit 1563409cb1
382 changed files with 45347 additions and 0 deletions
+69
View File
@@ -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);
}
}
+222
View File
@@ -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);
}
}
+33
View File
@@ -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;
}
}
+118
View File
@@ -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);
}
+6
View File
@@ -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');
}
+47
View File
@@ -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
+49
View File
@@ -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();
}
}
+241
View File
@@ -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);
}
}
+67
View File
@@ -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);
}
}
+28
View File
@@ -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;
}
}
+16
View File
@@ -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;
}
}
+501
View File
@@ -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;
}
}
+446
View File
@@ -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, "_");
}
+388
View File
@@ -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(),
});
}
}
+16
View File
@@ -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);
}
}
+22
View File
@@ -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";
}
}
+81
View File
@@ -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");
}
}
}
+10
View File
@@ -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;
}
}
+25
View File
@@ -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));
}
+148
View File
@@ -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);
}
}
+15
View File
@@ -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);
}
}