From 6a2238d5a68bf21bd6ba8ac0cb3058576f7cb7b7 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Tue, 16 Jan 2024 19:38:35 -0500 Subject: [PATCH 01/42] Move _includeItemTypes() to TabContentType --- .../MusicScreen/music_screen_tab_view.dart | 23 ++----------- lib/models/finamp_models.dart | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart index ee5767278..12a75d9ea 100644 --- a/lib/components/MusicScreen/music_screen_tab_view.dart +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -86,7 +86,7 @@ class _MusicScreenTabViewState extends State parentItem: widget.parentItem ?? widget.view ?? _finampUserHelper.currentUser?.currentView, - includeItemTypes: _includeItemTypes(widget.tabContentType), + includeItemTypes: widget.tabContentType.itemType(), // If we're on the songs tab, sort by "Album,SortName". This is what the // Jellyfin web client does. If this isn't the case, check if parentItem @@ -287,7 +287,7 @@ class _MusicScreenTabViewState extends State offlineSortedItems = downloadsHelper.downloadedParents .where((element) => element.item.type == - _includeItemTypes(widget.tabContentType) && + widget.tabContentType.itemType() && element.viewId == _finampUserHelper.currentUser!.currentViewId && (albumArtist == null || @@ -582,23 +582,6 @@ class _MusicScreenTabViewState extends State } } -String _includeItemTypes(TabContentType tabContentType) { - switch (tabContentType) { - case TabContentType.songs: - return "Audio"; - case TabContentType.albums: - return "MusicAlbum"; - case TabContentType.artists: - return "MusicArtist"; - case TabContentType.genres: - return "MusicGenre"; - case TabContentType.playlists: - return "Playlist"; - default: - throw const FormatException("Unsupported TabContentType"); - } -} - bool _offlineSearch( {required BaseItemDto item, required String searchTerm, @@ -612,5 +595,5 @@ bool _offlineSearch( containsName = item.name!.toLowerCase().contains(searchTerm.toLowerCase()); } - return item.type == _includeItemTypes(tabContentType) && containsName; + return item.type == tabContentType.itemType() && containsName; } diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index 2b015b1f4..97eba9af6 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -362,6 +362,40 @@ enum TabContentType { return AppLocalizations.of(context)!.playlists; } } + + static TabContentType fromItemType(String itemType) { + switch (itemType) { + case "Audio": + return TabContentType.songs; + case "MusicAlbum": + return TabContentType.albums; + case "MusicArtist": + return TabContentType.artists; + case "MusicGenre": + return TabContentType.genres; + case "Playlist": + return TabContentType.playlists; + default: + throw const FormatException("Unsupported itemType"); + } + } + + String itemType() { + switch (this) { + case TabContentType.songs: + return "Audio"; + case TabContentType.albums: + return "MusicAlbum"; + case TabContentType.artists: + return "MusicArtist"; + case TabContentType.genres: + return "MusicGenre"; + case TabContentType.playlists: + return "Playlist"; + default: + throw const FormatException("Unsupported TabContentType"); + } + } } @HiveType(typeId: 39) From 8da2b09784be8daf2956ad1f9335713aa3779100 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Wed, 17 Jan 2024 01:53:37 -0500 Subject: [PATCH 02/42] Basic Android Auto support --- android/app/src/main/AndroidManifest.xml | 17 +++ .../src/main/res/xml/automotive_app_desc.xml | 3 + lib/main.dart | 4 + lib/services/android_auto_helper.dart | 138 ++++++++++++++++++ .../music_player_background_task.dart | 97 ++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 lib/services/android_auto_helper.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 003a2116b..ace2984ea 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,8 +41,25 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 000000000..59ee4e3bb --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index b64bc7133..968bd9ecd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:finamp/screens/playback_history_screen.dart'; import 'package:finamp/screens/queue_restore_screen.dart'; +import 'package:finamp/services/android_auto_helper.dart'; import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:finamp/services/finamp_user_helper.dart'; import 'package:finamp/services/playback_history_service.dart'; @@ -224,6 +225,8 @@ Future _setupPlaybackServices() async { final session = await AudioSession.instance; session.configure(const AudioSessionConfiguration.music()); + GetIt.instance.registerSingleton(AndroidAutoHelper()); + final audioHandler = await AudioService.init( builder: () => MusicPlayerBackgroundTask(), config: AudioServiceConfig( @@ -232,6 +235,7 @@ Future _setupPlaybackServices() async { androidNotificationChannelName: "Playback", androidNotificationIcon: "mipmap/white", androidNotificationChannelId: "com.unicornsonlsd.finamp.audio", + // androidBrowsableRootExtras: { "android.media.browse.SEARCH_SUPPORTED" : true } ), ); // GetIt.instance.registerSingletonAsync( diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart new file mode 100644 index 000000000..0237dd401 --- /dev/null +++ b/lib/services/android_auto_helper.dart @@ -0,0 +1,138 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:collection/collection.dart'; +import 'package:get_it/get_it.dart'; + +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'downloads_helper.dart'; +import 'finamp_user_helper.dart'; +import 'jellyfin_api_helper.dart'; +import 'finamp_settings_helper.dart'; +import 'queue_service.dart'; + +class AndroidAutoHelper { + final _finampUserHelper = GetIt.instance(); + final _jellyfinApiHelper = GetIt.instance(); + final _downloadsHelper = GetIt.instance(); + + Future getItemFromId(String itemId) async { + if (itemId == '-1') return null; + return _downloadsHelper.getDownloadedParent(itemId)?.item ?? await _jellyfinApiHelper.getItemById(itemId); + } + + Future> getBaseItems(String type, String categoryId, String? itemId) async { + final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); + + // limit amount so it doesn't crash on large libraries + // TODO: somehow load more after the limit + // a problem with this is: how? i don't *think* there is a callback for scrolling. maybe there could be a button to load more? + const limit = 100; + + // if we are in offline mode, only get downloaded parents for categories + if (FinampSettingsHelper.finampSettings.isOffline) { + List baseItems = []; + + // root category, get all matching parents + if (categoryId == '-1') { + for (final downloadedParent in _downloadsHelper.downloadedParents) { + if (baseItems.length >= limit) break; + if (downloadedParent.item.type == tabContentType.itemType()) { + baseItems.add(downloadedParent.item); + } + } + } else { + // specific category, get all items in category + var downloadedParent = _downloadsHelper.getDownloadedParent(categoryId); + if (downloadedParent != null) { + baseItems.addAll([for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]); + } + } + return baseItems; + } + // fetch the online version if we aren't in offline mode + + final sortBy = tabContentType == TabContentType.artists ? "ProductionYear,PremiereDate" : "SortName"; + + // select the item type that each category holds + final includeItemTypes = categoryId != '-1' // if categoryId is -1, we are browsing a root library. e.g. browsing the list of all albums or artists + ? (tabContentType == TabContentType.albums ? TabContentType.songs.itemType() // get an album's songs + : tabContentType == TabContentType.artists ? TabContentType.albums.itemType() // get an artist's albums + : tabContentType == TabContentType.playlists ? TabContentType.songs.itemType() // get a playlist's songs + : tabContentType == TabContentType.genres ? TabContentType.albums.itemType() // get a genre's albums + : throw FormatException("Unsupported TabContentType `$tabContentType`")) + : tabContentType.itemType(); // get the root library + + // if category id is defined, use that to get items. + // otherwise, use the current view as fallback to ensure we get the correct items. + final parentItem = categoryId != '-1' + ? BaseItemDto(id: categoryId, type: tabContentType.itemType()) + : _finampUserHelper.currentUser?.currentView; + + final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy, includeItemTypes: includeItemTypes, isGenres: tabContentType == TabContentType.genres, limit: limit); + return items != null ? [for (final item in items) item] : []; + } + + Future> getMediaItems(String type, String categoryId, String? itemId) async { + return [ for (final item in await getBaseItems(type, categoryId, itemId)) await _convertToMediaItem(item, categoryId) ]; + } + + Future playFromMediaId(String type, String categoryId, String? itemId) async { + final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); + + // shouldn't happen, but just in case + if (!_isPlayable(tabContentType)) return; + + // get all songs in current category + final parentItem = await getItemFromId(categoryId); + final categoryBaseItems = await getBaseItems(type, categoryId, itemId); + + // queue service should be initialized by time we get here + final queueService = GetIt.instance(); + await queueService.startPlayback(items: categoryBaseItems, source: QueueItemSource( + type: tabContentType == TabContentType.playlists + ? QueueItemSourceType.playlist + : QueueItemSourceType.album, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem?.name), + id: parentItem?.id ?? categoryId, + item: parentItem, + )); + } + + // albums, playlists, and songs should play when clicked + // genres and artists have subcategories, so they should be browsable but not playable + bool _isPlayable(TabContentType tabContentType) { + return tabContentType == TabContentType.albums || tabContentType == TabContentType.playlists || tabContentType == TabContentType.songs; + } + + Future _convertToMediaItem(BaseItemDto item, String categoryId) async { + final tabContentType = TabContentType.fromItemType(item.type!); + var newId = '${tabContentType.name}|'; + // if item is a parent type (category), set newId to 'type|categoryId'. otherwise, if it's a specific item (song), set it to 'type|categoryId|itemId' + if (item.isFolder ?? tabContentType != TabContentType.songs && categoryId == '-1') { + newId += item.id; + } else { + newId += '$categoryId|${item.id}'; + } + + return MediaItem( + id: newId, + playable: _isPlayable(tabContentType), // this dictates whether clicking on an item will try to play it or browse it + album: item.album, + artist: item.artists?.join(", ") ?? item.albumArtist, + // TODO: may need to download image locally to display it - https://developer.android.com/training/cars/media#display-artwork + // specifically android automotive systems seem to not display artwork + // and offline mode doesn't display artwork either + // both of these probably need a custom ContentProvider + artUri: _downloadsHelper.getDownloadedImage(item)?.file.uri ?? + _jellyfinApiHelper.getImageUrl(item: item), // ?.replace(scheme: "content"), + title: item.name ?? "unknown", + // Jellyfin returns microseconds * 10 for some reason + duration: Duration( + microseconds: + (item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10), + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index c70e3806a..e145110c4 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -1,19 +1,30 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; +import 'dart:ui'; 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:get_it/get_it.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import 'package:logging/logging.dart'; import 'finamp_settings_helper.dart'; +import 'locale_helper.dart'; +import 'android_auto_helper.dart'; /// This provider handles the currently playing music so that multiple widgets /// can control music. class MusicPlayerBackgroundTask extends BaseAudioHandler { + final _androidAutoHelper = GetIt.instance(); + + AppLocalizations? _appLocalizations; + bool _localizationsInitialized = false; + final _player = AudioPlayer( audioLoadConfiguration: AudioLoadConfiguration( androidLoadControl: AndroidLoadControl( @@ -308,6 +319,92 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } } + // menus + @override + Future> getChildren(String parentMediaId, [Map? options]) async { + // display root category + if (parentMediaId == AudioService.browsableRootId) { + if (!_localizationsInitialized) { + _appLocalizations = await AppLocalizations.delegate.load( + LocaleHelper.locale ?? const Locale("en", "US")); + _localizationsInitialized = true; + } + + return !FinampSettingsHelper.finampSettings.isOffline ? [ + MediaItem( + id: '${TabContentType.albums.name}|-1', + title: _appLocalizations?.albums ?? TabContentType.albums.toString(), + playable: false + ), + MediaItem( + id: '${TabContentType.artists.name}|-1', + title: _appLocalizations?.artists ?? TabContentType.artists.toString(), + playable: false + ), + MediaItem( + id: '${TabContentType.playlists.name}|-1', + title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), + playable: false + ), + MediaItem( + id: '${TabContentType.genres.name}|-1', + title: _appLocalizations?.genres ?? TabContentType.genres.toString(), + playable: false + )] : [ // display only albums and playlists if in offline mode + MediaItem( + id: '${TabContentType.albums.name}|-1', + title: _appLocalizations?.albums ?? TabContentType.albums.toString(), + playable: false + ), + MediaItem( + id: '${TabContentType.playlists.name}|-1', + title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), + playable: false + ), + ]; + } + + final split = parentMediaId.split('|'); + if (split.length < 2) { + return super.getChildren(parentMediaId); + } + + return await _androidAutoHelper.getMediaItems(split[0], split[1], split.length == 3 ? split[2] : null); + } + + // play specific item + @override + Future playFromMediaId(String mediaId, [Map? extras]) async { + final split = mediaId.split('|'); + if (split.length < 2) { + return super.playFromMediaId(mediaId, extras); + } + + return await _androidAutoHelper.playFromMediaId(split[0], split[1], split.length == 3 ? split[2] : null); + } + + // // keyboard search + // @override + // Future> search(String query, [Map? extras]) async { + // List items = []; + // return items; + // } + // + // // voice search + // @override + // Future playFromSearch(String query, [Map? extras]) async { + // return; + // } + + // https://github.com/ryanheise/audio_service/blob/audio_service-v0.18.10/audio_service/example/lib/example_multiple_handlers.dart#L367 + // triggers when skipping to specific item in android auto queue + @override + Future skipToQueueItem(int index) async { + if (index < 0 || index >= queue.value.length) return; + // This jumps to the beginning of the queue item at newIndex. + _player.seek(Duration.zero, index: index); + } + void setNextInitialIndex(int index) { nextInitialIndex = index; } From d168fb702241812cebe564161bc101e26ab8b89b Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Wed, 17 Jan 2024 02:23:19 -0500 Subject: [PATCH 03/42] Try using downloaded parent before going online in Android Auto --- lib/services/android_auto_helper.dart | 31 ++++++++++++--------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 0237dd401..9408a9f97 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -28,28 +28,25 @@ class AndroidAutoHelper { // a problem with this is: how? i don't *think* there is a callback for scrolling. maybe there could be a button to load more? const limit = 100; - // if we are in offline mode, only get downloaded parents for categories - if (FinampSettingsHelper.finampSettings.isOffline) { + // if we are in offline mode and in root category, display all matching downloaded parents + if (FinampSettingsHelper.finampSettings.isOffline && categoryId == '-1') { List baseItems = []; - - // root category, get all matching parents - if (categoryId == '-1') { - for (final downloadedParent in _downloadsHelper.downloadedParents) { - if (baseItems.length >= limit) break; - if (downloadedParent.item.type == tabContentType.itemType()) { - baseItems.add(downloadedParent.item); - } - } - } else { - // specific category, get all items in category - var downloadedParent = _downloadsHelper.getDownloadedParent(categoryId); - if (downloadedParent != null) { - baseItems.addAll([for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]); + for (final downloadedParent in _downloadsHelper.downloadedParents) { + if (baseItems.length >= limit) break; + if (downloadedParent.item.type == tabContentType.itemType()) { + baseItems.add(downloadedParent.item); } } return baseItems; } - // fetch the online version if we aren't in offline mode + + // try to get downloaded parent first + var downloadedParent = _downloadsHelper.getDownloadedParent(categoryId); + if (downloadedParent != null) { + return [for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]; + } + + // fetch the online version if we can't get offline version final sortBy = tabContentType == TabContentType.artists ? "ProductionYear,PremiereDate" : "SortName"; From f8776245fe2eb511306a3de5bcfe072f4279f1d0 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Thu, 18 Jan 2024 16:20:21 -0500 Subject: [PATCH 04/42] Add shuffle to Android Auto --- android/app/build.gradle | 1 + .../baseline_shuffle_on_white_24.png | Bin 0 -> 313 bytes .../baseline_shuffle_white_24.png | Bin 0 -> 258 bytes .../res/drawable-mdpi/baseline_shuffle_24.xml | 10 ++++++++ .../drawable-mdpi/baseline_shuffle_on_24.xml | 11 +++++++++ .../baseline_shuffle_on_white_24.png | Bin 0 -> 232 bytes .../baseline_shuffle_white_24.png | Bin 0 -> 194 bytes .../baseline_shuffle_on_white_24.png | Bin 0 -> 386 bytes .../baseline_shuffle_white_24.png | Bin 0 -> 333 bytes .../baseline_shuffle_on_white_24.png | Bin 0 -> 554 bytes .../baseline_shuffle_white_24.png | Bin 0 -> 446 bytes .../baseline_shuffle_on_white_24.png | Bin 0 -> 720 bytes .../baseline_shuffle_white_24.png | Bin 0 -> 543 bytes lib/services/android_auto_helper.dart | 5 ++++ .../music_player_background_task.dart | 22 +++++++++++++++++- 15 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/res/drawable-hdpi/baseline_shuffle_on_white_24.png create mode 100644 android/app/src/main/res/drawable-hdpi/baseline_shuffle_white_24.png create mode 100644 android/app/src/main/res/drawable-mdpi/baseline_shuffle_24.xml create mode 100644 android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_24.xml create mode 100644 android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_white_24.png create mode 100644 android/app/src/main/res/drawable-mdpi/baseline_shuffle_white_24.png create mode 100644 android/app/src/main/res/drawable-xhdpi/baseline_shuffle_on_white_24.png create mode 100644 android/app/src/main/res/drawable-xhdpi/baseline_shuffle_white_24.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/baseline_shuffle_on_white_24.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/baseline_shuffle_white_24.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/baseline_shuffle_on_white_24.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/baseline_shuffle_white_24.png diff --git a/android/app/build.gradle b/android/app/build.gradle index 5e39d34ed..7288213a6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -77,4 +77,5 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' + implementation 'androidx.appcompat:appcompat:1.3.1' } diff --git a/android/app/src/main/res/drawable-hdpi/baseline_shuffle_on_white_24.png b/android/app/src/main/res/drawable-hdpi/baseline_shuffle_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..6702ba5fe97e164221802e01e8e0ce02bdc88836 GIT binary patch literal 313 zcmV-90mlA`P)V2%@ZxIFpMKjG8>WxQrVE&Ljzi9&+-q>n-Y1U0S6(_P$@suO$%xo<%cw`5lW+^ zKsHr5s1*~2Le$LQVm>eo`KW~{p-rx0kWKaTo62IQw8x=|!U-=AV<-XyXx#DMFqNm4 ziqax+h6GU<0;EwBm?%VLqRFl|?eavQo@^I$OfUbQ0Uqf;q(QOh>`*^Z;k1Cs5?=DI|%U27OGS{(5MfT>Q4x2G%i00000 LNkvXXu0mjfmji&? literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-hdpi/baseline_shuffle_white_24.png b/android/app/src/main/res/drawable-hdpi/baseline_shuffle_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..a11b977f58b9ba6e2a04243376e2cf94368d8924 GIT binary patch literal 258 zcmV+d0sa1oP) + + diff --git a/android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_24.xml b/android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_24.xml new file mode 100644 index 000000000..2c770f3f9 --- /dev/null +++ b/android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_white_24.png b/android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..8cd227093a6dcf7847262a765c60552bfce83f30 GIT binary patch literal 232 zcmVP)69gi*I7Xu4-v@m2~=C0-p zIJ5_z^yuk)B76rgfL?@XBPF1u3Zg^{s3A%ep(WITGm`BQWg^r8P!bK}!Jc<908k!< zy&7w=Y9%uRFzwbzKl{k~4>;Nz+BY&;vF?Z2=tO8)tys&8`yj!tMmnv!(Os1YNXZVE^!{yTR}Vd8$AihEl`DPm^?Vq-_{;x~6^8HX tR`30~sHVGpwU4s)^`7@{_TtMG~M=Bbp*Gj4j;$ZPDHlIH=X_;~ubZ9gY@N>t z(ym>yGlvMW;W$y__lTjs>38Rf24NRl)@*Ds4_-bxssgY){3`SHABgYodBZWk1e#xC gtuNx{zY^ZMuC?~F&kz;97ytkO07*qoM6N<$f zTMmLS5JgE$&}FDQ@`LAQ*bRw$Qlr}t~bp=A6!n`SetYj+8TLOW8Dk8eV<_<^-q_`0UJwcH##XdmIw$_pO`%utp%(Ass17>jsLi4*Pf_uI`jhEMQZQqSF?qNlHt`yIf@GCki96^@uizc1QYbfnHqioRU>^M=V~So|!zaw6 zr)Q#QjOPu+*kXqqkBl5UY!T}?6C5#A95GSf&H+Qlq4Acm!O*d(gZ6`i_H|G`I4G}! z0*DdB0cBXD^l1Hr7sE8{1Z{=|~F;lET5X+P!6KnLDXaICEU8 zBTl-wx{(SjG@RNbAYbiO((NJkoC=ANe%O(^Id>8#A5uwQ&YV9ID?zJ~@P@O2)7H~Y zyo~Wq!mRP%?K$KWwnVD=6he+Hr!Mi`c$k>OP4WpGCa6Fn=kP!z4hux%a6m*31Eg{i ze~uhfV9Df2L3EA`Wagal*^->}0J7n{YSy}*Kq_ac<7k)P9zZ2lwwz89Dg7{WfS<-M zX8k2-HXPuJ4+Dvm!rF1nBvO*Ea#kJlPCJRT4bpMCt3`SeX`|@Q(UVA9X+MsZ#3{%4 zattKjf|Z2RN#d0BdjZeAX^dxyUp*TBt=h9!ssI2007*qoM6N<$g63}h9RL6T literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/baseline_shuffle_white_24.png b/android/app/src/main/res/drawable-xxhdpi/baseline_shuffle_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..daad1689f6fa20645ee4b3190922c039593c4cb4 GIT binary patch literal 446 zcmV;v0YUzWP)i_;Q+u*niM|Rbuof4DSW5`bxIOv4y{RTI0+>N zxm6ZTLWy2**~gOH95%>G>QKgVB0yY{g)>a2H_@Ral(;XR=uu8r9Fa8X?k3qe^G$&0 zFp`@yB@v|{^=r~L=a)p3hV-_}vAjoAco3x^ed2k+={!GcMI=x9;BtgBl9n?g7iT10 zPQ?EOsVmQY^k~vEAI1Z9P zJBH2KBk?%JiWAt6b(46UI*v1v>Qqh7CTr7k^{SiPZQ*(2yvg1;pOI?$3mD!i`ytgS o?t{4d(7CRsG#ZUYqxl2*24bE8!;tPm*#H0l07*qoM6N<$g84JX4gdfE literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxxhdpi/baseline_shuffle_on_white_24.png b/android/app/src/main/res/drawable-xxxhdpi/baseline_shuffle_on_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..c24a9c4203b0b38483a750d3dea83a1ca99c7413 GIT binary patch literal 720 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!o0(?STf%N}@0LNB5Sp&3Nqa?^L zm_e)W>Zkv#lm6_Qc;%hTsd^KEll&)+upQ^&JIZ_PUU@}TP2JN{k&9fmyf4fc7#Qz+ zx;TbZFuuLTT6EY!fc4_WwWh|#hyUhFi1rpd~ejla7rgTsM34q3KGhm!u~)ld8u z+{h@PR9n0GFa!6-eOx?8S_FYYjXMltS?_*Yxt@vHFtPUXeA};~2?|O%8~EFG6>>K6 zGjLeHVP-fa_nYAWpQHKAgZv$w59}EHYT0(o7d}wSAoGhMpkC;W!#6wTJLlykoa7na z@$c|&Sh)WbOTm8&*;K|I`iq>@7RbN7&&^R#Z?NZeGXul-P=h}&lm8b?N^j(k-m%`= zp0nn`(OQ8A7bgBzIB-z^%WuVohW7V&*cU1@1jx7aJya3+rS-v6;d^94!*3mf1GPdN z59~O2GUD~`G!`T(+{(9(nQ-bx`hwIKxAn4b{o+`W@aA`X)VKW>|4gTUul>gV;ZOdR zpZBxA?2mj^zx(?Cwypnl%m25t{gJQ!asT=|-V@a`MGl%4HUg6egQu&X%Q~loCIAZh BamN4v literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxxhdpi/baseline_shuffle_white_24.png b/android/app/src/main/res/drawable-xxxhdpi/baseline_shuffle_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..8d1134655d8215b047cede4022620f376bab6c07 GIT binary patch literal 543 zcmV+)0^t3LP)jX)ClH6M-$m&m!AgynnkbcOn2ezmC&az7|@Zh*3%aa2qqlICB*0p z2S_Fa^uLq?q!I%9CsyZws(@X>CSCvXxd3FBzzvUK{qqFCHla$_Zzce?38(IaIQ{<# zKt>7i`hN+)6bbj~`acPPeZouYC)cg|J--@m0qcb4LHZj^Pb1-}IQ>S#W0Cs52@l2U z0|0M})(=F5^w+NM`hEJV()ACn@A|HPE&2zkA#hgu?XNuL=)d;8JLj$+@%nG6zZ{{T zPXd?RkJ8U0AxS@Gj*mp4|m7w z$0aC_*VFX-6O=DF()GI&l&_d%^g9xCFZqJ{qtst`mqTa>&GQztsj#hqaWHB z5?MXtj3252y87x1Pt)q_E&ZA8Ev6q)`r741LS5M6>sdl=y#NkKCe-%(>wsKB$?Nxy h<2a7vIF937A>V9E$#!+oNUi_?002ovPDHLkV1nEX1w{Y= literal 0 HcmV?d00001 diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 9408a9f97..9bb454718 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -73,6 +73,11 @@ class AndroidAutoHelper { return [ for (final item in await getBaseItems(type, categoryId, itemId)) await _convertToMediaItem(item, categoryId) ]; } + Future toggleShuffle() async { + final queueService = GetIt.instance(); + queueService.togglePlaybackOrder(); + } + Future playFromMediaId(String type, String categoryId, String? itemId) async { final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index e145110c4..d4758d3e7 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -20,6 +20,7 @@ import 'android_auto_helper.dart'; /// This provider handles the currently playing music so that multiple widgets /// can control music. class MusicPlayerBackgroundTask extends BaseAudioHandler { + var _androidAutoShuffleOn = false; final _androidAutoHelper = GetIt.instance(); AppLocalizations? _appLocalizations; @@ -396,6 +397,19 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // return; // } + @override + Future customAction(String name, [Map? extras]) async { + switch (name) { + case 'shuffle': + { + _androidAutoShuffleOn = !_androidAutoShuffleOn; + return await _androidAutoHelper.toggleShuffle(); + } + } + + return await super.customAction(name, extras); + } + // https://github.com/ryanheise/audio_service/blob/audio_service-v0.18.10/audio_service/example/lib/example_multiple_handlers.dart#L367 // triggers when skipping to specific item in android auto queue @override @@ -453,7 +467,13 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { if (_player.playing) MediaControl.pause else MediaControl.play, MediaControl.stop, MediaControl.skipToNext, - ], + MediaControl.custom( + androidIcon: _androidAutoShuffleOn + ? "drawable/baseline_shuffle_on_24" + : "drawable/baseline_shuffle_24", + label: "Shuffle", + name: "shuffle" + )], systemActions: const { MediaAction.seek, MediaAction.seekForward, From 56d4f2ac7f7189b89f0853eed8e2682578b20ec4 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Thu, 18 Jan 2024 16:35:19 -0500 Subject: [PATCH 05/42] Use correct shuffle status for Android Auto --- lib/services/music_player_background_task.dart | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index d4758d3e7..6ea3b268c 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -20,7 +20,6 @@ import 'android_auto_helper.dart'; /// This provider handles the currently playing music so that multiple widgets /// can control music. class MusicPlayerBackgroundTask extends BaseAudioHandler { - var _androidAutoShuffleOn = false; final _androidAutoHelper = GetIt.instance(); AppLocalizations? _appLocalizations; @@ -401,10 +400,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { Future customAction(String name, [Map? extras]) async { switch (name) { case 'shuffle': - { - _androidAutoShuffleOn = !_androidAutoShuffleOn; return await _androidAutoHelper.toggleShuffle(); - } } return await super.customAction(name, extras); @@ -468,7 +464,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { MediaControl.stop, MediaControl.skipToNext, MediaControl.custom( - androidIcon: _androidAutoShuffleOn + androidIcon: _player.shuffleModeEnabled ? "drawable/baseline_shuffle_on_24" : "drawable/baseline_shuffle_24", label: "Shuffle", From 05e5f7266d96c6179e6357fdb30aedcba4da8eef Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Sat, 20 Jan 2024 15:17:55 -0500 Subject: [PATCH 06/42] Artists now start instant mix in Android Auto --- lib/services/android_auto_helper.dart | 38 ++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 9bb454718..b85f484b8 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -9,15 +9,24 @@ import 'finamp_user_helper.dart'; import 'jellyfin_api_helper.dart'; import 'finamp_settings_helper.dart'; import 'queue_service.dart'; +import 'audio_service_helper.dart'; class AndroidAutoHelper { final _finampUserHelper = GetIt.instance(); final _jellyfinApiHelper = GetIt.instance(); final _downloadsHelper = GetIt.instance(); - Future getItemFromId(String itemId) async { - if (itemId == '-1') return null; - return _downloadsHelper.getDownloadedParent(itemId)?.item ?? await _jellyfinApiHelper.getItemById(itemId); + Future getParentFromId(String parentId) async { + if (parentId == '-1') return null; + + final downloadedParent = _downloadsHelper.getDownloadedParent(parentId)?.item; + if (downloadedParent != null) { + return downloadedParent; + } else if (FinampSettingsHelper.finampSettings.isOffline) { + return null; + } + + return await _jellyfinApiHelper.getItemById(parentId); } Future> getBaseItems(String type, String categoryId, String? itemId) async { @@ -82,10 +91,23 @@ class AndroidAutoHelper { final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); // shouldn't happen, but just in case - if (!_isPlayable(tabContentType)) return; + if (categoryId == '-1' || !_isPlayable(tabContentType)) return; // get all songs in current category - final parentItem = await getItemFromId(categoryId); + final parentItem = await getParentFromId(categoryId); + + // start instant mix for artists + if (tabContentType == TabContentType.artists) { + // we don't show artists in offline mode, and parent item can't be null for mix + // this shouldn't happen, but just in case + if (FinampSettingsHelper.finampSettings.isOffline || parentItem == null) { + return; + } + + final audioServiceHelper = GetIt.instance(); + return await audioServiceHelper.startInstantMixForArtists([parentItem]); + } + final categoryBaseItems = await getBaseItems(type, categoryId, itemId); // queue service should be initialized by time we get here @@ -103,9 +125,11 @@ class AndroidAutoHelper { } // albums, playlists, and songs should play when clicked - // genres and artists have subcategories, so they should be browsable but not playable + // clicking artists starts an instant mix, so they are technically playable + // genres has subcategories, so it should be browsable but not playable bool _isPlayable(TabContentType tabContentType) { - return tabContentType == TabContentType.albums || tabContentType == TabContentType.playlists || tabContentType == TabContentType.songs; + return tabContentType == TabContentType.albums || tabContentType == TabContentType.playlists + || tabContentType == TabContentType.artists || tabContentType == TabContentType.songs; } Future _convertToMediaItem(BaseItemDto item, String categoryId) async { From efb14bd978a97bc1072740fad6729bbd1874a2f4 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 21 Jan 2024 17:26:57 +0100 Subject: [PATCH 07/42] synchronize internal queue and Android media queue --- lib/services/music_player_background_task.dart | 9 +++------ lib/services/queue_service.dart | 17 ++++------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 6ea3b268c..6884cd111 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -235,8 +235,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { index: _player.shuffleModeEnabled ? _queueAudioSource.shuffleIndices[_queueAudioSource .shuffleIndices - .indexOf((_player.currentIndex ?? 0)) + - offset] + .indexOf(_player.currentIndex ?? 0) + offset] : (_player.currentIndex ?? 0) + offset); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); @@ -410,9 +409,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // triggers when skipping to specific item in android auto queue @override Future skipToQueueItem(int index) async { - if (index < 0 || index >= queue.value.length) return; - // This jumps to the beginning of the queue item at newIndex. - _player.seek(Duration.zero, index: index); + skipToIndex(index); } void setNextInitialIndex(int index) { @@ -487,7 +484,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, - queueIndex: event.currentIndex, + queueIndex: _player.shuffleModeEnabled && (shuffleIndices?.isNotEmpty ?? false) && event.currentIndex != null ? shuffleIndices!.indexOf(event.currentIndex!) : event.currentIndex, shuffleMode: _player.shuffleModeEnabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 6afa96ee6..2719c48db 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -94,14 +94,8 @@ class QueueService { _queueAudioSourceIndex = event.queueIndex ?? 0; if (previousIndex != _queueAudioSourceIndex) { - int adjustedQueueIndex = (playbackOrder == - FinampPlaybackOrder.shuffled && - _queueAudioSource.shuffleIndices.isNotEmpty) - ? _queueAudioSource.shuffleIndices.indexOf(_queueAudioSourceIndex) - : _queueAudioSourceIndex; - _queueServiceLogger.finer( - "Play queue index changed, new index: $adjustedQueueIndex (actual index: $_queueAudioSourceIndex)"); + "Play queue index changed, new index: _queueAudioSourceIndex"); _queueFromConcatenatingAudioSource(); } else { _saveUpdateImemdiate = true; @@ -145,10 +139,6 @@ class QueueService { ?.map((e) => e.tag as FinampQueueItem) .toList() ?? []; - int adjustedQueueIndex = (playbackOrder == FinampPlaybackOrder.shuffled && - _queueAudioSource.shuffleIndices.isNotEmpty) - ? _queueAudioSource.shuffleIndices.indexOf(_queueAudioSourceIndex) - : _queueAudioSourceIndex; final previousTrack = _currentTrack; final previousTracksPreviousLength = _queuePreviousTracks.length; @@ -163,7 +153,7 @@ class QueueService { // split the queue by old type for (int i = 0; i < allTracks.length; i++) { - if (i < adjustedQueueIndex) { + if (i < _queueAudioSourceIndex) { _queuePreviousTracks.add(allTracks[i]); if ([ QueueItemSourceType.nextUp, @@ -178,7 +168,7 @@ class QueueService { id: "former-next-up"); } _queuePreviousTracks.last.type = QueueItemQueueType.previousTracks; - } else if (i == adjustedQueueIndex) { + } else if (i == _queueAudioSourceIndex) { _currentTrack = allTracks[i]; _currentTrack!.type = QueueItemQueueType.currentTrack; } else { @@ -229,6 +219,7 @@ class QueueService { _audioHandler.mediaItem.add(_currentTrack?.item); _audioHandler.queue.add(_queuePreviousTracks .followedBy([_currentTrack!]) + .followedBy(_queueNextUp) .followedBy(_queue) .map((e) => e.item) .toList()); From 0789c31921ccccd510cc603fabdf10cb6bff82e1 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 21 Jan 2024 18:45:47 +0100 Subject: [PATCH 08/42] fix adding to next up when shuffle is active --- lib/services/queue_service.dart | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 2719c48db..c4f77a321 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -1006,32 +1006,8 @@ class NextUpShuffleOrder extends ShuffleOrder { @override void insert(int index, int count) { int insertionPoint = index; - int linearIndexOfPreviousItem = index - 1; - // _queueService!._queueFromConcatenatingAudioSource(); - // QueueInfo queueInfo = _queueService!.getQueue(); - - // // log indices - // String indicesString = ""; - // for (int index in indices) { - // indicesString += "$index, "; - // } - // _queueService!.queueServiceLogger.finest("Shuffled indices: $indicesString"); - // _queueService!.queueServiceLogger.finest("Current Track: ${queueInfo.currentTrack}"); - - if (index >= indices.length) { - // handle appending to the queue - insertionPoint = indices.length; - } else { - // handle adding to Next Up - int shuffledIndexOfPreviousItem = - indices.indexOf(linearIndexOfPreviousItem); - if (shuffledIndexOfPreviousItem != -1) { - insertionPoint = shuffledIndexOfPreviousItem + 1; - } - _queueService!.queueServiceLogger.finest( - "Inserting $count items at index $index (shuffled indices insertion point: $insertionPoint) (index of previous item: $shuffledIndexOfPreviousItem)"); - } + insertionPoint = index; // Offset indices after insertion point. for (var i = 0; i < indices.length; i++) { From 7a24508609b30ee40a89eb77f638de989a1cc1ff Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Mon, 22 Jan 2024 14:59:47 -0500 Subject: [PATCH 09/42] Add sorting to Android Auto and make offline sort by name case-insensitive --- .../MusicScreen/music_screen_tab_view.dart | 105 ++++++++---------- lib/services/android_auto_helper.dart | 80 +++++++++++-- 2 files changed, 121 insertions(+), 64 deletions(-) diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart index 12a75d9ea..1b81de491 100644 --- a/lib/components/MusicScreen/music_screen_tab_view.dart +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -325,63 +325,54 @@ class _MusicScreenTabViewState extends State } offlineSortedItems!.sort((a, b) { - // if (a.name == null || b.name == null) { - // // Returning 0 is the same as both being the same - // return 0; - // } else { - // return a.name!.compareTo(b.name!); - // } - if (a.name == null || b.name == null) { - // Returning 0 is the same as both being the same - return 0; - } else { - switch (widget.sortBy) { - case SortBy.sortName: - if (a.name == null || b.name == null) { - // Returning 0 is the same as both being the same - return 0; - } else { - return a.name!.compareTo(b.name!); - } - case SortBy.albumArtist: - if (a.albumArtist == null || b.albumArtist == null) { - return 0; - } else { - return a.albumArtist!.compareTo(b.albumArtist!); - } - case SortBy.communityRating: - if (a.communityRating == null || - b.communityRating == null) { - return 0; - } else { - return a.communityRating!.compareTo(b.communityRating!); - } - case SortBy.criticRating: - if (a.criticRating == null || b.criticRating == null) { - return 0; - } else { - return a.criticRating!.compareTo(b.criticRating!); - } - case SortBy.dateCreated: - if (a.dateCreated == null || b.dateCreated == null) { - return 0; - } else { - return a.dateCreated!.compareTo(b.dateCreated!); - } - case SortBy.premiereDate: - if (a.premiereDate == null || b.premiereDate == null) { - return 0; - } else { - return a.premiereDate!.compareTo(b.premiereDate!); - } - case SortBy.random: - // We subtract the result by one so that we can get -1 values - // (see comareTo documentation) - return Random().nextInt(2) - 1; - default: - throw UnimplementedError( - "Unimplemented offline sort mode ${widget.sortBy}"); - } + switch (widget.sortBy) { + case SortBy.sortName: + final aName = a.name?.trim().toLowerCase(); + final bName = b.name?.trim().toLowerCase(); + if (aName == null || bName == null) { + // Returning 0 is the same as both being the same + return 0; + } else { + return aName.compareTo(bName); + } + case SortBy.albumArtist: + if (a.albumArtist == null || b.albumArtist == null) { + return 0; + } else { + return a.albumArtist!.compareTo(b.albumArtist!); + } + case SortBy.communityRating: + if (a.communityRating == null || + b.communityRating == null) { + return 0; + } else { + return a.communityRating!.compareTo(b.communityRating!); + } + case SortBy.criticRating: + if (a.criticRating == null || b.criticRating == null) { + return 0; + } else { + return a.criticRating!.compareTo(b.criticRating!); + } + case SortBy.dateCreated: + if (a.dateCreated == null || b.dateCreated == null) { + return 0; + } else { + return a.dateCreated!.compareTo(b.dateCreated!); + } + case SortBy.premiereDate: + if (a.premiereDate == null || b.premiereDate == null) { + return 0; + } else { + return a.premiereDate!.compareTo(b.premiereDate!); + } + case SortBy.random: + // We subtract the result by one so that we can get -1 values + // (see compareTo documentation) + return Random().nextInt(2) - 1; + default: + throw UnimplementedError( + "Unimplemented offline sort mode ${widget.sortBy}"); } }); diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index b85f484b8..df9222d11 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; import 'package:get_it/get_it.dart'; @@ -29,7 +31,7 @@ class AndroidAutoHelper { return await _jellyfinApiHelper.getItemById(parentId); } - Future> getBaseItems(String type, String categoryId, String? itemId) async { + Future> getBaseItems(String type, String categoryId, String? itemId, { playing = false }) async { final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); // limit amount so it doesn't crash on large libraries @@ -37,6 +39,9 @@ class AndroidAutoHelper { // a problem with this is: how? i don't *think* there is a callback for scrolling. maybe there could be a button to load more? const limit = 100; + final sortBy = FinampSettingsHelper.finampSettings.getTabSortBy(tabContentType); + final sortOrder = FinampSettingsHelper.finampSettings.getSortOrder(tabContentType); + // if we are in offline mode and in root category, display all matching downloaded parents if (FinampSettingsHelper.finampSettings.isOffline && categoryId == '-1') { List baseItems = []; @@ -46,19 +51,17 @@ class AndroidAutoHelper { baseItems.add(downloadedParent.item); } } - return baseItems; + return _sortItems(baseItems, sortBy, sortOrder); } // try to get downloaded parent first var downloadedParent = _downloadsHelper.getDownloadedParent(categoryId); if (downloadedParent != null) { - return [for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]; + return _sortItems([for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child], sortBy, sortOrder); } // fetch the online version if we can't get offline version - final sortBy = tabContentType == TabContentType.artists ? "ProductionYear,PremiereDate" : "SortName"; - // select the item type that each category holds final includeItemTypes = categoryId != '-1' // if categoryId is -1, we are browsing a root library. e.g. browsing the list of all albums or artists ? (tabContentType == TabContentType.albums ? TabContentType.songs.itemType() // get an album's songs @@ -74,8 +77,8 @@ class AndroidAutoHelper { ? BaseItemDto(id: categoryId, type: tabContentType.itemType()) : _finampUserHelper.currentUser?.currentView; - final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy, includeItemTypes: includeItemTypes, isGenres: tabContentType == TabContentType.genres, limit: limit); - return items != null ? [for (final item in items) item] : []; + final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(tabContentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, isGenres: tabContentType == TabContentType.genres, limit: limit); + return items ?? []; } Future> getMediaItems(String type, String categoryId, String? itemId) async { @@ -124,6 +127,69 @@ class AndroidAutoHelper { )); } + // sort items + List _sortItems(List items, SortBy sortBy, SortOrder sortOrder) { + items.sort((a, b) { + switch (sortBy) { + case SortBy.sortName: + final aName = a.name?.trim().toLowerCase(); + final bName = b.name?.trim().toLowerCase(); + if (aName == null || bName == null) { + // Returning 0 is the same as both being the same + return 0; + } else { + return aName.compareTo(bName); + } + case SortBy.albumArtist: + if (a.albumArtist == null || b.albumArtist == null) { + return 0; + } else { + return a.albumArtist!.compareTo(b.albumArtist!); + } + case SortBy.communityRating: + if (a.communityRating == null || + b.communityRating == null) { + return 0; + } else { + return a.communityRating!.compareTo(b.communityRating!); + } + case SortBy.criticRating: + if (a.criticRating == null || b.criticRating == null) { + return 0; + } else { + return a.criticRating!.compareTo(b.criticRating!); + } + case SortBy.dateCreated: + if (a.dateCreated == null || b.dateCreated == null) { + return 0; + } else { + return a.dateCreated!.compareTo(b.dateCreated!); + } + case SortBy.premiereDate: + if (a.premiereDate == null || b.premiereDate == null) { + return 0; + } else { + return a.premiereDate!.compareTo(b.premiereDate!); + } + case SortBy.random: + // We subtract the result by one so that we can get -1 values + // (see compareTo documentation) + return Random().nextInt(2) - 1; + default: + throw UnimplementedError( + "Unimplemented offline sort mode $sortBy"); + } + }); + + if (sortOrder == SortOrder.descending) { + // The above sort functions sort in ascending order, so we swap them + // when sorting in descending order. + items = items.reversed.toList(); + } + + return items; + } + // albums, playlists, and songs should play when clicked // clicking artists starts an instant mix, so they are technically playable // genres has subcategories, so it should be browsable but not playable From aad1edfed79f8145cea214fddf12632f41f2b898 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Mon, 22 Jan 2024 15:01:41 -0500 Subject: [PATCH 10/42] Remove unused parameter in Android Auto getBaseItems --- lib/services/android_auto_helper.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index df9222d11..8e55f6fe3 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -31,7 +31,7 @@ class AndroidAutoHelper { return await _jellyfinApiHelper.getItemById(parentId); } - Future> getBaseItems(String type, String categoryId, String? itemId, { playing = false }) async { + Future> getBaseItems(String type, String categoryId, String? itemId) async { final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); // limit amount so it doesn't crash on large libraries From 99d01f2906be82f9ef2aed2b967211cf99604f85 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Mon, 22 Jan 2024 16:09:22 -0500 Subject: [PATCH 11/42] Only sort downloaded items if not playing them in Android Auto --- lib/services/android_auto_helper.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 8e55f6fe3..3a1f356d3 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -54,10 +54,14 @@ class AndroidAutoHelper { return _sortItems(baseItems, sortBy, sortOrder); } - // try to get downloaded parent first - var downloadedParent = _downloadsHelper.getDownloadedParent(categoryId); - if (downloadedParent != null) { - return _sortItems([for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child], sortBy, sortOrder); + // try to use downloaded parent first + if (categoryId != '-1') { + var downloadedParent = _downloadsHelper.getDownloadedParent(categoryId); + if (downloadedParent != null) { + final downloadedItems = [for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]; + // only sort items if we are not playing them + return _isPlayable(tabContentType) ? downloadedItems : _sortItems(downloadedItems, sortBy, sortOrder); + } } // fetch the online version if we can't get offline version From b3656c839c5a9f9f95a5a8630be5173140166fc4 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Tue, 23 Jan 2024 03:13:04 -0500 Subject: [PATCH 12/42] Use a ContentProvider to resolve artwork for Android Auto --- android/app/src/main/AndroidManifest.xml | 5 +++ .../com/unicornsonlsd/finamp/MainActivity.kt | 7 ++++ .../finamp/MediaItemContentProvider.kt | 8 ++++ images/album_white.png | Bin 0 -> 799 bytes lib/main.dart | 22 +++++++++++ lib/services/android_auto_helper.dart | 33 +++++++++++++--- lib/services/mediaitem_content_provider.dart | 37 ++++++++++++++++++ .../music_player_background_task.dart | 1 - lib/services/queue_service.dart | 29 +++++++++++++- pubspec.lock | 8 ++++ pubspec.yaml | 2 + 11 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/kotlin/com/unicornsonlsd/finamp/MediaItemContentProvider.kt create mode 100644 images/album_white.png create mode 100644 lib/services/mediaitem_content_provider.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ace2984ea..112aba948 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,11 @@ + + diff --git a/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt index a66d6cb31..9a7d0cd8e 100644 --- a/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt +++ b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt @@ -1,6 +1,13 @@ package com.unicornsonlsd.finamp +import android.content.Context +import com.nt4f04und.android_content_provider.AndroidContentProvider import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine class MainActivity: FlutterActivity() { + override fun provideFlutterEngine(context: Context): FlutterEngine? { + return AndroidContentProvider.getFlutterEngineGroup(this) + .createAndRunDefaultEngine(this) + } } diff --git a/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MediaItemContentProvider.kt b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MediaItemContentProvider.kt new file mode 100644 index 000000000..158fa10e8 --- /dev/null +++ b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MediaItemContentProvider.kt @@ -0,0 +1,8 @@ +package com.unicornsonlsd.finamp + +import com.nt4f04und.android_content_provider.AndroidContentProvider + +class MediaItemContentProvider : AndroidContentProvider() { + override val authority: String = "com.unicornsonlsd.finamp.MediaItemContentProvider" + override val entrypointName = "mediaItemContentProviderEntrypoint" +} \ No newline at end of file diff --git a/images/album_white.png b/images/album_white.png new file mode 100644 index 0000000000000000000000000000000000000000..f946d1c6ca69fb8d0818a35dd313a5e3b3614002 GIT binary patch literal 799 zcmV+)1K|9LP)YQn>)@q z;($#S#xwD0O4Mi(1TAWmm>!?x*QxV?6d%-CA9d#k`Fum z6&{HbkF13I?Z=z`KMDEpYX62&;3kx{KrW`GJA66Y+l&}CPR~u%HvJubMk?Tk9Xr$T z%Bb;bVK5@{JYtVz?l{K$s*hf4fb$YW|mo&aoD55 ziv{BoQ!)W%<4`9!WQTHQaX2yNElbj$Fg&VXQt;(2aA|n_p9n5s%^<)RmB9QJz6>I) z1s6~^sQRL)M2iM1q8@w$eHW*{D&FN*#S-2FZl;vP#Zxr;qb!OGsT7R3CZw$xTa}71 zQY{#5OSbb^xn0?g)Cxkm$hXMu7OVsmM0Wj~MSsc-(RXrcePB8j#oO>F+!ql#)&d++ zynTPdV*y=nxM>gx$kG!;JoYEN5YRP3>3TE-5ik7dt_5_vKHa1yUAi>AJ3QqD%TKj=U{gE;WhkEsG743~J^{vGuGc?#oFz}Rsku&u}e+d}7blZQL2VdP^LG$RbyB0JLe`hl4 z$M0Pcnjb*)4MsE`DV`@NeW$AT5s)iE_cN@%&CvT0?vD(;PZ0+SO7COP0U=tS openFile(String uri, String mode) async { + return Uri.parse(uri).replace(scheme: "file", host: "").toFilePath(); + } + + // Unused + + @override + Future delete(String uri, String? selection, List? selectionArgs) { + throw UnimplementedError(); + } + + @override + Future getType(String uri) { + return Future.value(null); + } + + @override + Future insert(String uri, ContentValues? values) { + throw UnimplementedError(); + } + + @override + Future query(String uri, List? projection, String? selection, List? selectionArgs, String? sortOrder) { + throw UnimplementedError(); + } + + @override + Future update(String uri, ContentValues? values, String? selection, List? selectionArgs) { + throw UnimplementedError(); + } +} \ No newline at end of file diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 6884cd111..c121f7892 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -405,7 +405,6 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { return await super.customAction(name, extras); } - // https://github.com/ryanheise/audio_service/blob/audio_service-v0.18.10/audio_service/example/lib/example_multiple_handlers.dart#L367 // triggers when skipping to specific item in android auto queue @override Future skipToQueueItem(int index) async { diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index c4f77a321..8dc9adc34 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -6,6 +6,7 @@ import 'package:just_audio/just_audio.dart'; import 'package:audio_service/audio_service.dart'; import 'package:get_it/get_it.dart'; import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; import 'package:collection/collection.dart'; @@ -818,12 +819,36 @@ class QueueService { ? false : await _downloadsHelper.verifyDownloadedSong(downloadedSong); + var downloadedImage = _downloadsHelper.getDownloadedImage(item); + Uri? artUri; + + // replace with content uri or jellyfin api uri + if (downloadedImage != null) { + artUri = downloadedImage.file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); + } else if (!FinampSettingsHelper.finampSettings.isOffline) { + artUri = _jellyfinApiHelper.getImageUrl(item: item); + // try to get image file for Android Automotive + // if (artUri != null) { + // try { + // final file = (await AudioService.cacheManager.getFileFromMemory(item.id))?.file ?? await AudioService.cacheManager.getSingleFile(artUri.toString()); + // artUri = file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); + // } catch (e, st) { + // _queueServiceLogger.fine("Error getting image file for Android Automotive", e, st); + // } + // } + } + + // replace with placeholder art + if (artUri == null) { + final documentsDirectory = await getApplicationDocumentsDirectory(); + artUri = Uri(scheme: "content", host: "com.unicornsonlsd.finamp", path: "${documentsDirectory.absolute.path}/images/album_white.png"); + } + return MediaItem( id: uuid.v4(), album: item.album ?? "unknown", artist: item.artists?.join(", ") ?? item.albumArtist, - artUri: _downloadsHelper.getDownloadedImage(item)?.file.uri ?? - _jellyfinApiHelper.getImageUrl(item: item), + artUri: artUri, title: item.name ?? "unknown", extras: { "itemJson": item.toJson(), diff --git a/pubspec.lock b/pubspec.lock index d869f439a..ccd01b597 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + android_content_provider: + dependency: "direct main" + description: + name: android_content_provider + sha256: "847db497f837334c11d72d4a71b1c496562f0fd7c97710315e0ad5ee9cbfe593" + url: "https://pub.dev" + source: hosted + version: "0.4.1" android_id: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0d01d5a22..9a52f3fcc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,7 @@ dependencies: flutter_vibrate: ^1.3.0 flutter_downloader: ^1.11.5 mini_music_visualizer: ^1.0.2 + android_content_provider: ^0.4.1 dev_dependencies: flutter_test: @@ -106,6 +107,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - images/finamp.png + - images/album_white.png # - images/a_dot_ham.jpeg generate: true From ea10a210476a15abca4dd0c843e24d08126d9c64 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Sat, 3 Feb 2024 19:52:37 -0500 Subject: [PATCH 13/42] Basic voice search for songs in Android Auto --- android/app/src/main/AndroidManifest.xml | 4 ++ .../music_player_background_task.dart | 39 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 112aba948..99a4ca1a2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,10 @@ + + + + items = []; // return items; // } - // - // // voice search - // @override - // Future playFromSearch(String query, [Map? extras]) async { - // return; - // } + + // voice search + @override + Future playFromSearch(String query, [Map? extras]) async { + _audioServiceBackgroundTaskLogger.info("playFromSearch: $query ; extras: $extras"); + + final jellyfinApiHelper = GetIt.instance(); + final finampUserHelper = GetIt.instance(); + final audioServiceHelper = GetIt.instance(); + + try { + final searchResult = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: "Audio", + searchTerm: query.trim(), + isGenres: false, + startIndex: 0, + limit: 1, + ); + + if (searchResult!.isEmpty) { + return; + } + + _audioServiceBackgroundTaskLogger.info("Playing from search query: ${searchResult[0].name}"); + return await audioServiceHelper.startInstantMixForItem(searchResult[0]).then((value) => 1); + } catch (err) { + _audioServiceBackgroundTaskLogger.severe("Error while playing from search query:", err); + } + } @override Future customAction(String name, [Map? extras]) async { From 430ee623baf38ecbafb360e8cca474c234420b5c Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Tue, 6 Feb 2024 10:46:12 +0100 Subject: [PATCH 14/42] enable showing search results for android auto voice search --- lib/main.dart | 6 ++-- lib/services/android_auto_helper.dart | 31 +++++++++++++++++-- .../music_player_background_task.dart | 14 +++++---- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index e3bc2e862..fd54400cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -252,7 +252,9 @@ Future _setupPlaybackServices() async { androidNotificationChannelName: "Playback", androidNotificationIcon: "mipmap/white", androidNotificationChannelId: "com.unicornsonlsd.finamp.audio", - // androidBrowsableRootExtras: { "android.media.browse.SEARCH_SUPPORTED" : true } + androidBrowsableRootExtras: { + "android.media.browse.SEARCH_SUPPORTED" : true, // support showing alternative search results for Android Auto voice search on the player screen + } ), ); // GetIt.instance.registerSingletonAsync( @@ -476,4 +478,4 @@ class _DummyCallback { @pragma('vm:entry-point') void mediaItemContentProviderEntrypoint() { MediaItemContentProvider('com.unicornsonlsd.finamp.MediaItemContentProvider'); -} \ No newline at end of file +} diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 6bf8239e8..8cf22e7a9 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -6,6 +6,7 @@ import 'package:get_it/get_it.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:finamp/models/finamp_models.dart'; +import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'downloads_helper.dart'; import 'finamp_user_helper.dart'; @@ -15,6 +16,9 @@ import 'queue_service.dart'; import 'audio_service_helper.dart'; class AndroidAutoHelper { + + static final _androidAutoHelperLogger = Logger("AndroidAutoHelper"); + final _finampUserHelper = GetIt.instance(); final _jellyfinApiHelper = GetIt.instance(); final _downloadsHelper = GetIt.instance(); @@ -86,6 +90,27 @@ class AndroidAutoHelper { return items ?? []; } + Future> searchItems(String query, String? categoryId) async { + final jellyfinApiHelper = GetIt.instance(); + final finampUserHelper = GetIt.instance(); + + try { + final searchResult = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: "Audio", + searchTerm: query.trim(), + isGenres: false, + startIndex: 0, + limit: 20, + ); + + return [ for (final item in searchResult ?? []) await _convertToMediaItem(item, categoryId) ]; + } catch (err) { + _androidAutoHelperLogger.severe("Error while searching:", err); + return []; + } + } + Future> getMediaItems(String type, String categoryId, String? itemId) async { return [ for (final item in await getBaseItems(type, categoryId, itemId)) await _convertToMediaItem(item, categoryId) ]; } @@ -203,11 +228,11 @@ class AndroidAutoHelper { || tabContentType == TabContentType.artists || tabContentType == TabContentType.songs; } - Future _convertToMediaItem(BaseItemDto item, String categoryId) async { + Future _convertToMediaItem(BaseItemDto item, String? categoryId) async { final tabContentType = TabContentType.fromItemType(item.type!); var newId = '${tabContentType.name}|'; // if item is a parent type (category), set newId to 'type|categoryId'. otherwise, if it's a specific item (song), set it to 'type|categoryId|itemId' - if (item.isFolder ?? tabContentType != TabContentType.songs && categoryId == '-1') { + if (item.isFolder ?? tabContentType != TabContentType.songs && (categoryId == null || categoryId == '-1')) { newId += item.id; } else { newId += '$categoryId|${item.id}'; @@ -252,4 +277,4 @@ class AndroidAutoHelper { ), ); } -} \ No newline at end of file +} diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index c0c135e9b..7b6270bb4 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -372,12 +372,14 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { return await _androidAutoHelper.playFromMediaId(split[0], split[1], split.length == 3 ? split[2] : null); } - // // keyboard search - // @override - // Future> search(String query, [Map? extras]) async { - // List items = []; - // return items; - // } + // keyboard search + @override + Future> search(String query, [Map? extras]) async { + + final String? category = null; + return await _androidAutoHelper.searchItems(query, category); + + } // voice search @override From c85c07b032e8847a09524d49289f204141a8e440 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Tue, 6 Feb 2024 16:23:06 +0100 Subject: [PATCH 15/42] rename use parent instead of category - throughout Jellyfin and Finamp, everything is a parent or a child - parents aren't necessarily categories --- lib/services/android_auto_helper.dart | 48 +++++++++++-------- .../music_player_background_task.dart | 5 +- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 8cf22e7a9..835c28851 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -36,7 +36,7 @@ class AndroidAutoHelper { return await _jellyfinApiHelper.getItemById(parentId); } - Future> getBaseItems(String type, String categoryId, String? itemId) async { + Future> getBaseItems(String type, String parentId, String? itemId) async { final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); // limit amount so it doesn't crash on large libraries @@ -47,8 +47,8 @@ class AndroidAutoHelper { final sortBy = FinampSettingsHelper.finampSettings.getTabSortBy(tabContentType); final sortOrder = FinampSettingsHelper.finampSettings.getSortOrder(tabContentType); - // if we are in offline mode and in root category, display all matching downloaded parents - if (FinampSettingsHelper.finampSettings.isOffline && categoryId == '-1') { + // if we are in offline mode and in root parent/collection, display all matching downloaded parents + if (FinampSettingsHelper.finampSettings.isOffline && parentId == '-1') { List baseItems = []; for (final downloadedParent in _downloadsHelper.downloadedParents) { if (baseItems.length >= limit) break; @@ -60,8 +60,8 @@ class AndroidAutoHelper { } // try to use downloaded parent first - if (categoryId != '-1') { - var downloadedParent = _downloadsHelper.getDownloadedParent(categoryId); + if (parentId != '-1') { + var downloadedParent = _downloadsHelper.getDownloadedParent(parentId); if (downloadedParent != null) { final downloadedItems = [for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]; // only sort items if we are not playing them @@ -71,8 +71,8 @@ class AndroidAutoHelper { // fetch the online version if we can't get offline version - // select the item type that each category holds - final includeItemTypes = categoryId != '-1' // if categoryId is -1, we are browsing a root library. e.g. browsing the list of all albums or artists + // select the item type that each parent holds + final includeItemTypes = parentId != '-1' // if parentId is -1, we are browsing a root library. e.g. browsing the list of all albums or artists ? (tabContentType == TabContentType.albums ? TabContentType.songs.itemType() // get an album's songs : tabContentType == TabContentType.artists ? TabContentType.albums.itemType() // get an artist's albums : tabContentType == TabContentType.playlists ? TabContentType.songs.itemType() // get a playlist's songs @@ -80,10 +80,10 @@ class AndroidAutoHelper { : throw FormatException("Unsupported TabContentType `$tabContentType`")) : tabContentType.itemType(); // get the root library - // if category id is defined, use that to get items. + // if parent id is defined, use that to get items. // otherwise, use the current view as fallback to ensure we get the correct items. - final parentItem = categoryId != '-1' - ? BaseItemDto(id: categoryId, type: tabContentType.itemType()) + final parentItem = parentId != '-1' + ? BaseItemDto(id: parentId, type: tabContentType.itemType()) : _finampUserHelper.currentUser?.currentView; final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(tabContentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, isGenres: tabContentType == TabContentType.genres, limit: limit); @@ -111,8 +111,8 @@ class AndroidAutoHelper { } } - Future> getMediaItems(String type, String categoryId, String? itemId) async { - return [ for (final item in await getBaseItems(type, categoryId, itemId)) await _convertToMediaItem(item, categoryId) ]; + Future> getMediaItems(String type, String parentId, String? itemId) async { + return [ for (final item in await getBaseItems(type, parentId, itemId)) await _convertToMediaItem(item, parentId) ]; } Future toggleShuffle() async { @@ -120,15 +120,22 @@ class AndroidAutoHelper { queueService.togglePlaybackOrder(); } - Future playFromMediaId(String type, String categoryId, String? itemId) async { + Future playFromMediaId(String type, String parentId, String? itemId) async { + final audioServiceHelper = GetIt.instance(); final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); // shouldn't happen, but just in case + if (parentId == '-1' || !_isPlayable(tabContentType)) { + _androidAutoHelperLogger.warning("Tried to play from media id with non-playable item type $type"); + }; if (categoryId == '-1' || !_isPlayable(tabContentType)) return; // get all songs in current category final parentItem = await getParentFromId(categoryId); + // get all songs of current parrent + final parentItem = await getParentFromId(parentId); + // start instant mix for artists if (tabContentType == TabContentType.artists) { // we don't show artists in offline mode, and parent item can't be null for mix @@ -137,22 +144,21 @@ class AndroidAutoHelper { return; } - final audioServiceHelper = GetIt.instance(); return await audioServiceHelper.startInstantMixForArtists([parentItem]); } - final categoryBaseItems = await getBaseItems(type, categoryId, itemId); + final parentBaseItems = await getBaseItems(type, parentId, itemId); // queue service should be initialized by time we get here final queueService = GetIt.instance(); - await queueService.startPlayback(items: categoryBaseItems, source: QueueItemSource( + await queueService.startPlayback(items: parentBaseItems, source: QueueItemSource( type: tabContentType == TabContentType.playlists ? QueueItemSourceType.playlist : QueueItemSourceType.album, name: QueueItemSourceName( type: QueueItemSourceNameType.preTranslated, pretranslatedName: parentItem?.name), - id: parentItem?.id ?? categoryId, + id: parentItem?.id ?? parentId, item: parentItem, )); } @@ -228,14 +234,14 @@ class AndroidAutoHelper { || tabContentType == TabContentType.artists || tabContentType == TabContentType.songs; } - Future _convertToMediaItem(BaseItemDto item, String? categoryId) async { + Future _convertToMediaItem(BaseItemDto item, String parentId) async { final tabContentType = TabContentType.fromItemType(item.type!); var newId = '${tabContentType.name}|'; - // if item is a parent type (category), set newId to 'type|categoryId'. otherwise, if it's a specific item (song), set it to 'type|categoryId|itemId' - if (item.isFolder ?? tabContentType != TabContentType.songs && (categoryId == null || categoryId == '-1')) { + // if item is a parent type (category/collection), set newId to 'type|parentId'. otherwise, if it's a specific item (song), set it to 'type|parentId|itemId' + if (item.isFolder ?? tabContentType != TabContentType.songs && parentId == '-1') { newId += item.id; } else { - newId += '$categoryId|${item.id}'; + newId += '$parentId|${item.id}'; } var downloadedImage = _downloadsHelper.getDownloadedImage(item); diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 7b6270bb4..2093ebea7 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -311,7 +311,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // menus @override Future> getChildren(String parentMediaId, [Map? options]) async { - // display root category + // display root category/parent if (parentMediaId == AudioService.browsableRootId) { if (!_localizationsInitialized) { _appLocalizations = await AppLocalizations.delegate.load( @@ -376,8 +376,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { @override Future> search(String query, [Map? extras]) async { - final String? category = null; - return await _androidAutoHelper.searchItems(query, category); + return await _androidAutoHelper.searchItems(query); } From 121a87e1ab67ac0713e3a6d7f0062b220ac6e9ac Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Tue, 6 Feb 2024 16:23:29 +0100 Subject: [PATCH 16/42] implement starting instant mixes from search results --- lib/main.dart | 2 +- lib/services/android_auto_helper.dart | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index fd54400cc..6aed1b409 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -253,7 +253,7 @@ Future _setupPlaybackServices() async { androidNotificationIcon: "mipmap/white", androidNotificationChannelId: "com.unicornsonlsd.finamp.audio", androidBrowsableRootExtras: { - "android.media.browse.SEARCH_SUPPORTED" : true, // support showing alternative search results for Android Auto voice search on the player screen + "android.media.browse.SEARCH_SUPPORTED" : true, // support showing search button on Android Auto as well as alternative search results on the player screen after voice search } ), ); diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 835c28851..d769b4f99 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -77,7 +77,7 @@ class AndroidAutoHelper { : tabContentType == TabContentType.artists ? TabContentType.albums.itemType() // get an artist's albums : tabContentType == TabContentType.playlists ? TabContentType.songs.itemType() // get a playlist's songs : tabContentType == TabContentType.genres ? TabContentType.albums.itemType() // get a genre's albums - : throw FormatException("Unsupported TabContentType `$tabContentType`")) + : TabContentType.songs.itemType() ) // if we don't have one of these categories, we are probably dealing with stray songs : tabContentType.itemType(); // get the root library // if parent id is defined, use that to get items. @@ -90,7 +90,7 @@ class AndroidAutoHelper { return items ?? []; } - Future> searchItems(String query, String? categoryId) async { + Future> searchItems(String query) async { final jellyfinApiHelper = GetIt.instance(); final finampUserHelper = GetIt.instance(); @@ -104,7 +104,8 @@ class AndroidAutoHelper { limit: 20, ); - return [ for (final item in searchResult ?? []) await _convertToMediaItem(item, categoryId) ]; + const parentItemSignalInstantMix = "-2"; + return [ for (final item in searchResult!) await _convertToMediaItem(item, parentItemSignalInstantMix) ]; } catch (err) { _androidAutoHelperLogger.severe("Error while searching:", err); return []; @@ -128,10 +129,10 @@ class AndroidAutoHelper { if (parentId == '-1' || !_isPlayable(tabContentType)) { _androidAutoHelperLogger.warning("Tried to play from media id with non-playable item type $type"); }; - if (categoryId == '-1' || !_isPlayable(tabContentType)) return; - // get all songs in current category - final parentItem = await getParentFromId(categoryId); + if (parentId == '-2') { + return await audioServiceHelper.startInstantMixForItem(await _jellyfinApiHelper.getItemById(itemId!)); + } // get all songs of current parrent final parentItem = await getParentFromId(parentId); @@ -244,6 +245,11 @@ class AndroidAutoHelper { newId += '$parentId|${item.id}'; } + final downloadedSong = _downloadsHelper.getDownloadedSong(item.id); + final isDownloaded = downloadedSong == null + ? false + : await _downloadsHelper.verifyDownloadedSong(downloadedSong); + var downloadedImage = _downloadsHelper.getDownloadedImage(item); Uri? artUri; @@ -276,6 +282,14 @@ class AndroidAutoHelper { artist: item.artists?.join(", ") ?? item.albumArtist, artUri: artUri, title: item.name ?? "unknown", + extras: { + "itemJson": item.toJson(), + "shouldTranscode": FinampSettingsHelper.finampSettings.shouldTranscode, + "downloadedSongJson": isDownloaded + ? (_downloadsHelper.getDownloadedSong(item.id))!.toJson() + : null, + "isOffline": FinampSettingsHelper.finampSettings.isOffline, + }, // Jellyfin returns microseconds * 10 for some reason duration: Duration( microseconds: From 8d2d82522fe369e2ad987ae00d547269d3685da5 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Wed, 7 Feb 2024 23:18:21 +0100 Subject: [PATCH 17/42] improved playBySearch using extras - switched to `minor` branch for audio_service for the time being - supports playing albums (in order) and artists (as artists instant mix) in addition to songs - for albums, say "play by " or "play album by " (first version only works if there's no song with that title by that artist) - for artists, say "play songs by " (just saying "play " will result in Google Assistant choosing a random song from that artist instead - for songs, say "play by " or "play song by " - supports filtering songs and album results by artist using metadata - first 25 results are fetched, and the artist string provided by Google Assistant is matched against the AlbumArtists field of the BaseItemDto, checking if any of the album artists are contained within the string (Google Assistant returns multiple artists as a comma-delimited list) --- lib/components/PlayerScreen/queue_list.dart | 2 +- lib/services/android_auto_helper.dart | 153 ++++++++++++++++-- .../music_player_background_task.dart | 29 +--- pubspec.lock | 11 +- pubspec.yaml | 7 +- 5 files changed, 161 insertions(+), 41 deletions(-) diff --git a/lib/components/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart index 23af35e7e..a67227b8b 100644 --- a/lib/components/PlayerScreen/queue_list.dart +++ b/lib/components/PlayerScreen/queue_list.dart @@ -773,7 +773,7 @@ class _CurrentTrackState extends State { width: (screenSize.width - 2 * horizontalPadding - albumImageSize) * - (playbackPosition!.inMilliseconds / + ((playbackPosition?.inMilliseconds ?? 0) / (mediaState?.mediaItem ?.duration ?? const Duration( diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index d769b4f99..e9277a006 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -90,19 +90,60 @@ class AndroidAutoHelper { return items ?? []; } - Future> searchItems(String query) async { + Future> searchItems(String query, Map? extras) async { final jellyfinApiHelper = GetIt.instance(); final finampUserHelper = GetIt.instance(); try { - final searchResult = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: "Audio", - searchTerm: query.trim(), - isGenres: false, - startIndex: 0, - limit: 20, - ); + + List? searchResult; + + if (extras?["android.intent.extra.title"] != null) { + // search for exact query first, then search for adjusted query + // sometimes Google's adjustment might not be what we want, but sometimes it actually helps + List? searchResultExactQuery; + List? searchResultAdjustedQuery; + try { + searchResultExactQuery = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: "Audio", + searchTerm: query.trim(), + isGenres: false, + startIndex: 0, + limit: 7, + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for exact query:", e); + } + try { + searchResultAdjustedQuery = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: "Audio", + searchTerm: extras!["android.intent.extra.title"].trim(), + isGenres: false, + startIndex: 0, + limit: (searchResultExactQuery != null && searchResultExactQuery.isNotEmpty) ? 13 : 20, + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for adjusted query:", e); + } + + searchResult = searchResultExactQuery?.followedBy(searchResultAdjustedQuery ?? []).toList() ?? []; + + } else { + searchResult = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: "Audio", + searchTerm: query.trim(), + isGenres: false, + startIndex: 0, + limit: 20, + ); + } + + if (searchResult != null && searchResult.isEmpty) { + _androidAutoHelperLogger.warning("No search results found for query: $query (extras: $extras)"); + } const parentItemSignalInstantMix = "-2"; return [ for (final item in searchResult!) await _convertToMediaItem(item, parentItemSignalInstantMix) ]; @@ -112,6 +153,100 @@ class AndroidAutoHelper { } } + Future playFromSearch(String query, Map? extras) async { + final jellyfinApiHelper = GetIt.instance(); + final finampUserHelper = GetIt.instance(); + final audioServiceHelper = GetIt.instance(); + final queueService = GetIt.instance(); + + String itemType = "Audio"; + String? alternativeQuery; + + if (extras?["android.intent.extra.album"] != null && extras?["android.intent.extra.artist"] != null && extras?["android.intent.extra.title"] != null) { + // if all metadata is provided, search for song + itemType = "Audio"; + alternativeQuery = extras?["android.intent.extra.title"]; + } else if (extras?["android.intent.extra.album"] != null && extras?["android.intent.extra.artist"] != null && extras?["android.intent.extra.title"] == null) { + // if only album is provided, search for album + itemType = "MusicAlbum"; + alternativeQuery = extras?["android.intent.extra.album"]; + } else if (extras?["android.intent.extra.artist"] != null && extras?["android.intent.extra.title"] == null) { + // if only artist is provided, search for artist + itemType = "MusicArtist"; + alternativeQuery = extras?["android.intent.extra.artist"]; + } + + _androidAutoHelperLogger.info("Searching for: $itemType that matches query '${alternativeQuery ?? query}'"); + + try { + List? searchResult = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: itemType, + searchTerm: alternativeQuery?.trim() ?? query.trim(), + isGenres: false, + startIndex: 0, + limit: 25, // get more than the first result so we can filter using additional metadata + ); + + if (searchResult == null || searchResult.isEmpty) { + + if (alternativeQuery != null) { + // try again with metadata provided by android (could be corrected based on metadata or localizations) + + searchResult = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: itemType, + searchTerm: alternativeQuery.trim(), + isGenres: false, + startIndex: 0, + limit: 25, // get more than the first result so we can filter using additional metadata + ); + + } + + if (searchResult == null || searchResult.isEmpty) { + return; + } + } + + final selectedResult = searchResult.firstWhere((element) { + if (itemType == "Audio" && extras?["android.intent.extra.artist"] != null) { + return element.albumArtists?.any((artist) => extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true; + } else if (itemType == "MusicAlbum" && extras?["android.intent.extra.artist"] != null) { + return element.albumArtists?.any((artist) => extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true; + } else { + return false; + } + }, orElse: () => searchResult![0] + ); + + _androidAutoHelperLogger.info("Playing from search: ${selectedResult.name}"); + + if (itemType == "MusicAlbum") { + final album = await jellyfinApiHelper.getItemById(selectedResult.id); + final items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: "Audio", isGenres: false, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + _androidAutoHelperLogger.info("Playing album: ${album.name} (${items?.length} songs)"); + + queueService.startPlayback(items: items ?? [], source: QueueItemSource( + type: QueueItemSourceType.album, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: album.name), + id: album.id, + item: album, + ), + order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? + ); + } else if (itemType == "MusicArtist") { + await audioServiceHelper.startInstantMixForArtists([selectedResult]).then((value) => 1); + } else { + await audioServiceHelper.startInstantMixForItem(selectedResult).then((value) => 1); + } + } catch (err) { + _androidAutoHelperLogger.severe("Error while playing from search query:", err); + } + } + Future> getMediaItems(String type, String parentId, String? itemId) async { return [ for (final item in await getBaseItems(type, parentId, itemId)) await _convertToMediaItem(item, parentId) ]; } diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 2093ebea7..a7ccb66d6 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -8,6 +8,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:finamp/services/offline_listen_helper.dart'; import 'package:get_it/get_it.dart'; import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; @@ -375,8 +376,9 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // keyboard search @override Future> search(String query, [Map? extras]) async { + _audioServiceBackgroundTaskLogger.info("search: $query ; extras: $extras"); - return await _androidAutoHelper.searchItems(query); + return await _androidAutoHelper.searchItems(query, extras); } @@ -384,30 +386,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { @override Future playFromSearch(String query, [Map? extras]) async { _audioServiceBackgroundTaskLogger.info("playFromSearch: $query ; extras: $extras"); - - final jellyfinApiHelper = GetIt.instance(); - final finampUserHelper = GetIt.instance(); - final audioServiceHelper = GetIt.instance(); - - try { - final searchResult = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: "Audio", - searchTerm: query.trim(), - isGenres: false, - startIndex: 0, - limit: 1, - ); - - if (searchResult!.isEmpty) { - return; - } - - _audioServiceBackgroundTaskLogger.info("Playing from search query: ${searchResult[0].name}"); - return await audioServiceHelper.startInstantMixForItem(searchResult[0]).then((value) => 1); - } catch (err) { - _audioServiceBackgroundTaskLogger.severe("Error while playing from search query:", err); - } + await _androidAutoHelper.playFromSearch(query, extras); } @override diff --git a/pubspec.lock b/pubspec.lock index 4dcdd75b0..909b53fb7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -60,11 +60,12 @@ packages: audio_service: dependency: "direct main" description: - name: audio_service - sha256: a4d989f1225ea9621898d60f23236dcbfc04876fa316086c23c5c4af075dbac4 - url: "https://pub.dev" - source: hosted - version: "0.18.12" + path: audio_service + ref: HEAD + resolved-ref: ed856d6a074ff6d4a9d9681b0a13993c1f51f4e8 + url: "https://github.com/ryanheise/audio_service.git" + source: git + version: "0.18.13" audio_service_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bee51dde3..8392ca359 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,12 @@ dependencies: chopper: ^7.0.3 get_it: ^7.2.0 just_audio: ^0.9.35 - audio_service: ^0.18.12 + # audio_service: ^0.18.12 + audio_service: + git: + url: https://github.com/ryanheise/audio_service.git + branch: minor + path: audio_service audio_session: ^0.1.16 rxdart: ^0.27.7 simple_gesture_detector: ^0.2.0 From 0c34cfc7adc4c1021ea1b32f4a82c13be6764f6a Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Thu, 15 Feb 2024 19:46:18 -0500 Subject: [PATCH 18/42] Download images in MediaItemContentProvider Fixes artwork on Android Automotive --- lib/services/android_auto_helper.dart | 23 +++++++------ lib/services/mediaitem_content_provider.dart | 34 ++++++++++++++++++-- lib/services/queue_service.dart | 23 +++++++------ pubspec.lock | 2 +- pubspec.yaml | 1 + 5 files changed, 62 insertions(+), 21 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index e9277a006..a88ae7a67 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -393,15 +393,20 @@ class AndroidAutoHelper { artUri = downloadedImage.file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); } else if (!FinampSettingsHelper.finampSettings.isOffline) { artUri = _jellyfinApiHelper.getImageUrl(item: item); - // try to get image file for Android Automotive - // if (artUri != null) { - // try { - // final file = (await AudioService.cacheManager.getFileFromMemory(item.id))?.file ?? await AudioService.cacheManager.getSingleFile(artUri.toString()); - // artUri = file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); - // } catch (e, st) { - // _androidAutoHelperLogger.fine("Error getting image file for Android Automotive", e, st); - // } - // } + // try to get image file (Android Automotive needs this) + if (artUri != null) { + try { + final fileInfo = await AudioService.cacheManager.getFileFromCache(item.id); + if (fileInfo != null) { + artUri = fileInfo.file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); + } else { + // store the origin in fragment since it should be unused + artUri = artUri.replace(scheme: "content", host: "com.unicornsonlsd.finamp", fragment: artUri.origin); + } + } catch (e) { + _androidAutoHelperLogger.severe("Error setting new media artwork uri for item: ${item.id} name: ${item.name}", e); + } + } } // replace with placeholder art diff --git a/lib/services/mediaitem_content_provider.dart b/lib/services/mediaitem_content_provider.dart index 2fad91d64..c385d4304 100644 --- a/lib/services/mediaitem_content_provider.dart +++ b/lib/services/mediaitem_content_provider.dart @@ -1,11 +1,41 @@ import 'package:android_content_provider/android_content_provider.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:logging/logging.dart'; class MediaItemContentProvider extends AndroidContentProvider { - MediaItemContentProvider(String authority) : super(authority); + static final _mediaItemContentProviderLogger = Logger("MediaItemContentProvider"); + + late final DefaultCacheManager _cacheManager; + + MediaItemContentProvider(String authority) : super(authority) { + WidgetsFlutterBinding.ensureInitialized(); // needed for cache manager + _cacheManager = DefaultCacheManager(); + } @override Future openFile(String uri, String mode) async { - return Uri.parse(uri).replace(scheme: "file", host: "").toFilePath(); + final parsedUri = Uri.tryParse(uri); + if (parsedUri == null) { + _mediaItemContentProviderLogger.severe("Unknown uri in media item content provider: $uri"); + return uri; + } + + // we store the original scheme://host in fragment since it should be unused + if (parsedUri.hasFragment) { + final origin = Uri.parse(parsedUri.fragment); + final fixedUri = parsedUri.replace(scheme: origin.scheme, host: origin.host).removeFragment().toString(); + try { + final imageFile = await _cacheManager.getSingleFile(fixedUri, key: fixedUri); + return imageFile.path; + } catch (e) { + _mediaItemContentProviderLogger.severe("Failed resolving uri in media item content provider: $fixedUri"); + return fixedUri; + } + } + + // this means it's a local image (downloaded or placeholder art) + return Uri.parse(uri).path; } // Unused diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index a80ce7de9..19c754186 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -827,15 +827,20 @@ class QueueService { artUri = downloadedImage.file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); } else if (!FinampSettingsHelper.finampSettings.isOffline) { artUri = _jellyfinApiHelper.getImageUrl(item: item); - // try to get image file for Android Automotive - // if (artUri != null) { - // try { - // final file = (await AudioService.cacheManager.getFileFromMemory(item.id))?.file ?? await AudioService.cacheManager.getSingleFile(artUri.toString()); - // artUri = file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); - // } catch (e, st) { - // _queueServiceLogger.fine("Error getting image file for Android Automotive", e, st); - // } - // } + // try to get image file (Android Automotive needs this) + if (artUri != null) { + try { + final fileInfo = await AudioService.cacheManager.getFileFromCache(item.id); + if (fileInfo != null) { + artUri = fileInfo.file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); + } else { + // store the origin in fragment since it should be unused + artUri = artUri.replace(scheme: "content", host: "com.unicornsonlsd.finamp", fragment: artUri.origin); + } + } catch (e) { + _queueServiceLogger.severe("Error setting new media artwork uri for item: ${item.id} name: ${item.name}", e); + } + } } // replace with placeholder art diff --git a/pubspec.lock b/pubspec.lock index 909b53fb7..799e7f6b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -368,7 +368,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" diff --git a/pubspec.yaml b/pubspec.yaml index 8392ca359..4331ae573 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,6 +85,7 @@ dependencies: mini_music_visualizer: ^1.0.2 android_content_provider: ^0.4.1 balanced_text: ^0.0.3 + flutter_cache_manager: ^3.3.1 dev_dependencies: flutter_test: From bcc22c00bc0d4fed2b4edf79bbdfcf7805e4f740 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Fri, 16 Feb 2024 21:00:49 +0100 Subject: [PATCH 19/42] replaced manual passing and parsing of media IDs with serializable MediaItemId class --- lib/models/finamp_models.dart | 45 +++++++ lib/models/finamp_models.g.dart | 116 ++++++++++++++++++ lib/services/android_auto_helper.dart | 84 ++++++++----- .../music_player_background_task.dart | 44 ++++--- pubspec.lock | 4 +- pubspec.yaml | 2 +- 6 files changed, 244 insertions(+), 51 deletions(-) diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index 2dbd6bb60..5b21f76b8 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -996,3 +997,47 @@ enum SavedQueueState { pendingSave, } +@HiveType(typeId: 63) +enum MediaItemParentType { + @HiveField(0) + collection, + @HiveField(1) + rootCollection, + @HiveField(2) + instantMix, +} + +@JsonSerializable() +@HiveType(typeId: 64) +class MediaItemId { + + MediaItemId({ + required this.contentType, + required this.parentType, + this.itemId, + this.parentId, + }); + + @HiveField(0) + TabContentType contentType; + + @HiveField(1) + MediaItemParentType parentType; + + @HiveField(2) + String? itemId; + + @HiveField(3) + String? parentId; + + factory MediaItemId.fromJson(Map json) => + _$MediaItemIdFromJson(json); + + Map toJson() => _$MediaItemIdToJson(this); + + @override + String toString() { + return jsonEncode(toJson()); + } + +} diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index 307b4238d..b0f51c36b 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -748,6 +748,49 @@ class FinampStorableQueueInfoAdapter typeId == other.typeId; } +class MediaItemIdAdapter extends TypeAdapter { + @override + final int typeId = 64; + + @override + MediaItemId read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MediaItemId( + contentType: fields[0] as TabContentType, + parentType: fields[1] as MediaItemParentType, + itemId: fields[2] as String?, + parentId: fields[3] as String?, + ); + } + + @override + void write(BinaryWriter writer, MediaItemId obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.contentType) + ..writeByte(1) + ..write(obj.parentType) + ..writeByte(2) + ..write(obj.itemId) + ..writeByte(3) + ..write(obj.parentId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MediaItemIdAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + class TabContentTypeAdapter extends TypeAdapter { @override final int typeId = 36; @@ -1226,6 +1269,50 @@ class SavedQueueStateAdapter extends TypeAdapter { typeId == other.typeId; } +class MediaItemParentTypeAdapter extends TypeAdapter { + @override + final int typeId = 63; + + @override + MediaItemParentType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return MediaItemParentType.collection; + case 1: + return MediaItemParentType.rootCollection; + case 2: + return MediaItemParentType.instantMix; + default: + return MediaItemParentType.collection; + } + } + + @override + void write(BinaryWriter writer, MediaItemParentType obj) { + switch (obj) { + case MediaItemParentType.collection: + writer.writeByte(0); + break; + case MediaItemParentType.rootCollection: + writer.writeByte(1); + break; + case MediaItemParentType.instantMix: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MediaItemParentTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** @@ -1258,3 +1345,32 @@ Map _$DownloadedSongToJson(DownloadedSong instance) => 'isPathRelative': instance.isPathRelative, 'downloadLocationId': instance.downloadLocationId, }; + +MediaItemId _$MediaItemIdFromJson(Map json) => MediaItemId( + contentType: $enumDecode(_$TabContentTypeEnumMap, json['contentType']), + parentType: $enumDecode(_$MediaItemParentTypeEnumMap, json['parentType']), + itemId: json['itemId'] as String?, + parentId: json['parentId'] as String?, + ); + +Map _$MediaItemIdToJson(MediaItemId instance) => + { + 'contentType': _$TabContentTypeEnumMap[instance.contentType]!, + 'parentType': _$MediaItemParentTypeEnumMap[instance.parentType]!, + 'itemId': instance.itemId, + 'parentId': instance.parentId, + }; + +const _$TabContentTypeEnumMap = { + TabContentType.albums: 'albums', + TabContentType.artists: 'artists', + TabContentType.playlists: 'playlists', + TabContentType.genres: 'genres', + TabContentType.songs: 'songs', +}; + +const _$MediaItemParentTypeEnumMap = { + MediaItemParentType.collection: 'collection', + MediaItemParentType.rootCollection: 'rootCollection', + MediaItemParentType.instantMix: 'instantMix', +}; diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index a88ae7a67..5c80da53d 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -24,8 +24,6 @@ class AndroidAutoHelper { final _downloadsHelper = GetIt.instance(); Future getParentFromId(String parentId) async { - if (parentId == '-1') return null; - final downloadedParent = _downloadsHelper.getDownloadedParent(parentId)?.item; if (downloadedParent != null) { return downloadedParent; @@ -36,8 +34,8 @@ class AndroidAutoHelper { return await _jellyfinApiHelper.getItemById(parentId); } - Future> getBaseItems(String type, String parentId, String? itemId) async { - final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); + Future> getBaseItems(MediaItemId itemId) async { + final tabContentType = TabContentType.values.firstWhere((e) => e == itemId.contentType); // limit amount so it doesn't crash on large libraries // TODO: somehow load more after the limit @@ -48,7 +46,7 @@ class AndroidAutoHelper { final sortOrder = FinampSettingsHelper.finampSettings.getSortOrder(tabContentType); // if we are in offline mode and in root parent/collection, display all matching downloaded parents - if (FinampSettingsHelper.finampSettings.isOffline && parentId == '-1') { + if (FinampSettingsHelper.finampSettings.isOffline && itemId.parentType == MediaItemParentType.rootCollection) { List baseItems = []; for (final downloadedParent in _downloadsHelper.downloadedParents) { if (baseItems.length >= limit) break; @@ -60,8 +58,8 @@ class AndroidAutoHelper { } // try to use downloaded parent first - if (parentId != '-1') { - var downloadedParent = _downloadsHelper.getDownloadedParent(parentId); + if (itemId.parentType == MediaItemParentType.collection) { + var downloadedParent = _downloadsHelper.getDownloadedParent(itemId.parentId!); if (downloadedParent != null) { final downloadedItems = [for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]; // only sort items if we are not playing them @@ -72,7 +70,7 @@ class AndroidAutoHelper { // fetch the online version if we can't get offline version // select the item type that each parent holds - final includeItemTypes = parentId != '-1' // if parentId is -1, we are browsing a root library. e.g. browsing the list of all albums or artists + final includeItemTypes = itemId.parentType == MediaItemParentType.collection // if parentId is -1, we are browsing a root library. e.g. browsing the list of all albums or artists ? (tabContentType == TabContentType.albums ? TabContentType.songs.itemType() // get an album's songs : tabContentType == TabContentType.artists ? TabContentType.albums.itemType() // get an artist's albums : tabContentType == TabContentType.playlists ? TabContentType.songs.itemType() // get a playlist's songs @@ -82,14 +80,28 @@ class AndroidAutoHelper { // if parent id is defined, use that to get items. // otherwise, use the current view as fallback to ensure we get the correct items. - final parentItem = parentId != '-1' - ? BaseItemDto(id: parentId, type: tabContentType.itemType()) + final parentItem = itemId.parentType == MediaItemParentType.collection + ? BaseItemDto(id: itemId.parentId!, type: tabContentType.itemType()) : _finampUserHelper.currentUser?.currentView; final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(tabContentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, isGenres: tabContentType == TabContentType.genres, limit: limit); return items ?? []; } + Future> getRecentItems() async { + final finampUserHelper = GetIt.instance(); + final jellyfinApiHelper = GetIt.instance(); + final queueService = GetIt.instance(); + + try { + final recentItems = queueService.getNextXTracksInQueue(0, reverse: 5); + return [ for (final item in recentItems ?? []) await _convertToMediaItem(item: item, parentType: MediaItemParentType.rootCollection) ]; + } catch (err) { + _androidAutoHelperLogger.severe("Error while getting recent items:", err); + return []; + } + } + Future> searchItems(String query, Map? extras) async { final jellyfinApiHelper = GetIt.instance(); final finampUserHelper = GetIt.instance(); @@ -145,8 +157,7 @@ class AndroidAutoHelper { _androidAutoHelperLogger.warning("No search results found for query: $query (extras: $extras)"); } - const parentItemSignalInstantMix = "-2"; - return [ for (final item in searchResult!) await _convertToMediaItem(item, parentItemSignalInstantMix) ]; + return [ for (final item in searchResult!) await _convertToMediaItem(item: item, parentType: MediaItemParentType.instantMix) ]; } catch (err) { _androidAutoHelperLogger.severe("Error while searching:", err); return []; @@ -247,8 +258,8 @@ class AndroidAutoHelper { } } - Future> getMediaItems(String type, String parentId, String? itemId) async { - return [ for (final item in await getBaseItems(type, parentId, itemId)) await _convertToMediaItem(item, parentId) ]; + Future> getMediaItems(MediaItemId itemId) async { + return [ for (final item in await getBaseItems(itemId)) await _convertToMediaItem(item: item, parentType: itemId.parentType, parentId: itemId.parentId) ]; } Future toggleShuffle() async { @@ -256,21 +267,25 @@ class AndroidAutoHelper { queueService.togglePlaybackOrder(); } - Future playFromMediaId(String type, String parentId, String? itemId) async { + Future playFromMediaId(MediaItemId itemId) async { final audioServiceHelper = GetIt.instance(); - final tabContentType = TabContentType.values.firstWhere((e) => e.name == type); + final tabContentType = TabContentType.values.firstWhere((e) => e == itemId.contentType); // shouldn't happen, but just in case - if (parentId == '-1' || !_isPlayable(tabContentType)) { - _androidAutoHelperLogger.warning("Tried to play from media id with non-playable item type $type"); - }; + if (itemId.parentType == MediaItemParentType.rootCollection || !_isPlayable(tabContentType)) { + _androidAutoHelperLogger.warning("Tried to play from media id with non-playable item type ${itemId.parentType.name}"); + return; + } - if (parentId == '-2') { - return await audioServiceHelper.startInstantMixForItem(await _jellyfinApiHelper.getItemById(itemId!)); + if (itemId.parentType == MediaItemParentType.instantMix) { + return await audioServiceHelper.startInstantMixForItem(await _jellyfinApiHelper.getItemById(itemId.itemId!)); } + if (itemId.parentType != MediaItemParentType.collection || itemId.parentId == null) { + return; + } // get all songs of current parrent - final parentItem = await getParentFromId(parentId); + final parentItem = await getParentFromId(itemId.parentId!); // start instant mix for artists if (tabContentType == TabContentType.artists) { @@ -283,7 +298,7 @@ class AndroidAutoHelper { return await audioServiceHelper.startInstantMixForArtists([parentItem]); } - final parentBaseItems = await getBaseItems(type, parentId, itemId); + final parentBaseItems = await getBaseItems(itemId); // queue service should be initialized by time we get here final queueService = GetIt.instance(); @@ -294,7 +309,7 @@ class AndroidAutoHelper { name: QueueItemSourceName( type: QueueItemSourceNameType.preTranslated, pretranslatedName: parentItem?.name), - id: parentItem?.id ?? parentId, + id: parentItem?.id ?? itemId.parentId!, item: parentItem, )); } @@ -370,15 +385,18 @@ class AndroidAutoHelper { || tabContentType == TabContentType.artists || tabContentType == TabContentType.songs; } - Future _convertToMediaItem(BaseItemDto item, String parentId) async { + Future _convertToMediaItem({ + required BaseItemDto item, + required MediaItemParentType parentType, + String? parentId, + }) async { final tabContentType = TabContentType.fromItemType(item.type!); - var newId = '${tabContentType.name}|'; - // if item is a parent type (category/collection), set newId to 'type|parentId'. otherwise, if it's a specific item (song), set it to 'type|parentId|itemId' - if (item.isFolder ?? tabContentType != TabContentType.songs && parentId == '-1') { - newId += item.id; - } else { - newId += '$parentId|${item.id}'; - } + final itemId = MediaItemId( + contentType: tabContentType, + parentType: parentType, + parentId: parentId, + itemId: item.id, + ); final downloadedSong = _downloadsHelper.getDownloadedSong(item.id); final isDownloaded = downloadedSong == null @@ -416,7 +434,7 @@ class AndroidAutoHelper { } return MediaItem( - id: newId, + id: itemId.toString(), playable: _isPlayable(tabContentType), // this dictates whether clicking on an item will try to play it or browse it album: item.album, artist: item.artists?.join(", ") ?? item.albumArtist, diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index a7ccb66d6..07a3405ce 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:ui'; @@ -312,6 +313,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // menus @override Future> getChildren(String parentMediaId, [Map? options]) async { + // display root category/parent if (parentMediaId == AudioService.browsableRootId) { if (!_localizationsInitialized) { @@ -322,55 +324,67 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { return !FinampSettingsHelper.finampSettings.isOffline ? [ MediaItem( - id: '${TabContentType.albums.name}|-1', + id: MediaItemId(contentType: TabContentType.albums, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.albums ?? TabContentType.albums.toString(), playable: false ), MediaItem( - id: '${TabContentType.artists.name}|-1', + id: MediaItemId(contentType: TabContentType.artists, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.artists ?? TabContentType.artists.toString(), playable: false ), MediaItem( - id: '${TabContentType.playlists.name}|-1', + id: MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), playable: false ), MediaItem( - id: '${TabContentType.genres.name}|-1', + id: MediaItemId(contentType: TabContentType.genres, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.genres ?? TabContentType.genres.toString(), playable: false )] : [ // display only albums and playlists if in offline mode MediaItem( - id: '${TabContentType.albums.name}|-1', + id: MediaItemId(contentType: TabContentType.albums, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.albums ?? TabContentType.albums.toString(), playable: false ), MediaItem( - id: '${TabContentType.playlists.name}|-1', + id: MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), playable: false ), ]; } + // else if (parentMediaId == AudioService.recentRootId) { + // return await _androidAutoHelper.getRecentItems(); + // } + + try { + final itemId = MediaItemId.fromJson(jsonDecode(parentMediaId)); - final split = parentMediaId.split('|'); - if (split.length < 2) { + return await _androidAutoHelper.getMediaItems(itemId); + + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); return super.getChildren(parentMediaId); } - - return await _androidAutoHelper.getMediaItems(split[0], split[1], split.length == 3 ? split[2] : null); } // play specific item @override Future playFromMediaId(String mediaId, [Map? extras]) async { - final split = mediaId.split('|'); - if (split.length < 2) { - return super.playFromMediaId(mediaId, extras); - } + try { + + final mediaItemId = MediaItemId.fromJson(jsonDecode(mediaId)); + + if (mediaItemId.parentType == MediaItemParentType.rootCollection) { + return super.playFromMediaId(mediaId, extras); + } - return await _androidAutoHelper.playFromMediaId(split[0], split[1], split.length == 3 ? split[2] : null); + return await _androidAutoHelper.playFromMediaId(mediaItemId); + } catch (e) { + + } } // keyboard search diff --git a/pubspec.lock b/pubspec.lock index 799e7f6b8..6ddb77c35 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -206,10 +206,10 @@ packages: dependency: "direct dev" description: name: chopper_generator - sha256: "31e74e3b0cf6d694822c9fea0d28e28dc05e8523336c7aa109e40a61c467c048" + sha256: "89d40e458fa21eb1e8f2f87961548d834f24d287e23fcac17b57ab58978ccd06" url: "https://pub.dev" source: hosted - version: "7.1.1" + version: "7.0.6" cli_util: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4331ae573..f2cf1bc56 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,7 +91,7 @@ dev_dependencies: flutter_test: sdk: flutter build_runner: ^2.3.3 - chopper_generator: ^7.0.1 + chopper_generator: 7.0.6 hive_generator: ^2.0.0 json_serializable: ^6.6.1 flutter_launcher_icons: ^0.13.1 From c0355e27fa973b0f6c90a934a0ec32d25cbb8d26 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sat, 17 Feb 2024 00:55:16 +0100 Subject: [PATCH 20/42] use item ID instead of parent ID - also fixed the parentTypes where necessary --- lib/services/android_auto_helper.dart | 19 ++++++++++--------- .../music_player_background_task.dart | 7 ++----- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 5c80da53d..e1e084911 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -59,7 +59,7 @@ class AndroidAutoHelper { // try to use downloaded parent first if (itemId.parentType == MediaItemParentType.collection) { - var downloadedParent = _downloadsHelper.getDownloadedParent(itemId.parentId!); + var downloadedParent = _downloadsHelper.getDownloadedParent(itemId.itemId!); if (downloadedParent != null) { final downloadedItems = [for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]; // only sort items if we are not playing them @@ -81,7 +81,7 @@ class AndroidAutoHelper { // if parent id is defined, use that to get items. // otherwise, use the current view as fallback to ensure we get the correct items. final parentItem = itemId.parentType == MediaItemParentType.collection - ? BaseItemDto(id: itemId.parentId!, type: tabContentType.itemType()) + ? BaseItemDto(id: itemId.itemId!, type: tabContentType.itemType()) : _finampUserHelper.currentUser?.currentView; final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(tabContentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, isGenres: tabContentType == TabContentType.genres, limit: limit); @@ -95,7 +95,7 @@ class AndroidAutoHelper { try { final recentItems = queueService.getNextXTracksInQueue(0, reverse: 5); - return [ for (final item in recentItems ?? []) await _convertToMediaItem(item: item, parentType: MediaItemParentType.rootCollection) ]; + return [ for (final item in recentItems ?? []) await _convertToMediaItem(item: item, parentType: MediaItemParentType.collection) ]; } catch (err) { _androidAutoHelperLogger.severe("Error while getting recent items:", err); return []; @@ -157,7 +157,7 @@ class AndroidAutoHelper { _androidAutoHelperLogger.warning("No search results found for query: $query (extras: $extras)"); } - return [ for (final item in searchResult!) await _convertToMediaItem(item: item, parentType: MediaItemParentType.instantMix) ]; + return [ for (final item in searchResult!) await _convertToMediaItem(item: item, parentType: MediaItemParentType.instantMix, parentId: item.parentId) ]; } catch (err) { _androidAutoHelperLogger.severe("Error while searching:", err); return []; @@ -259,7 +259,7 @@ class AndroidAutoHelper { } Future> getMediaItems(MediaItemId itemId) async { - return [ for (final item in await getBaseItems(itemId)) await _convertToMediaItem(item: item, parentType: itemId.parentType, parentId: itemId.parentId) ]; + return [ for (final item in await getBaseItems(itemId)) await _convertToMediaItem(item: item, parentType: MediaItemParentType.collection, parentId: item.parentId) ]; } Future toggleShuffle() async { @@ -272,7 +272,7 @@ class AndroidAutoHelper { final tabContentType = TabContentType.values.firstWhere((e) => e == itemId.contentType); // shouldn't happen, but just in case - if (itemId.parentType == MediaItemParentType.rootCollection || !_isPlayable(tabContentType)) { + if (!_isPlayable(tabContentType)) { _androidAutoHelperLogger.warning("Tried to play from media id with non-playable item type ${itemId.parentType.name}"); return; } @@ -281,11 +281,12 @@ class AndroidAutoHelper { return await audioServiceHelper.startInstantMixForItem(await _jellyfinApiHelper.getItemById(itemId.itemId!)); } - if (itemId.parentType != MediaItemParentType.collection || itemId.parentId == null) { + if (itemId.parentType != MediaItemParentType.collection || itemId.itemId == null) { + _androidAutoHelperLogger.warning("Tried to play from media id with invalid parent type '${itemId.parentType.name}' or null id"); return; } // get all songs of current parrent - final parentItem = await getParentFromId(itemId.parentId!); + final parentItem = await getParentFromId(itemId.itemId!); // start instant mix for artists if (tabContentType == TabContentType.artists) { @@ -394,7 +395,7 @@ class AndroidAutoHelper { final itemId = MediaItemId( contentType: tabContentType, parentType: parentType, - parentId: parentId, + parentId: parentId ?? item.parentId, itemId: item.id, ); diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 07a3405ce..4ec933f22 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -377,13 +377,10 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { final mediaItemId = MediaItemId.fromJson(jsonDecode(mediaId)); - if (mediaItemId.parentType == MediaItemParentType.rootCollection) { - return super.playFromMediaId(mediaId, extras); - } - return await _androidAutoHelper.playFromMediaId(mediaItemId); } catch (e) { - + _audioServiceBackgroundTaskLogger.severe(e); + return super.playFromMediaId(mediaId, extras); } } From 72cc75a30d79b3ac362bf340b25f1a9371638e14 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Fri, 16 Feb 2024 19:36:00 -0500 Subject: [PATCH 21/42] Remove redundant TabContentType resolves in Android Auto code --- lib/services/android_auto_helper.dart | 36 ++++++++++++--------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index e1e084911..20b7209c0 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -35,22 +35,19 @@ class AndroidAutoHelper { } Future> getBaseItems(MediaItemId itemId) async { - final tabContentType = TabContentType.values.firstWhere((e) => e == itemId.contentType); - // limit amount so it doesn't crash on large libraries - // TODO: somehow load more after the limit - // a problem with this is: how? i don't *think* there is a callback for scrolling. maybe there could be a button to load more? + // TODO: add pagination const limit = 100; - final sortBy = FinampSettingsHelper.finampSettings.getTabSortBy(tabContentType); - final sortOrder = FinampSettingsHelper.finampSettings.getSortOrder(tabContentType); + final sortBy = FinampSettingsHelper.finampSettings.getTabSortBy(itemId.contentType); + final sortOrder = FinampSettingsHelper.finampSettings.getSortOrder(itemId.contentType); // if we are in offline mode and in root parent/collection, display all matching downloaded parents if (FinampSettingsHelper.finampSettings.isOffline && itemId.parentType == MediaItemParentType.rootCollection) { List baseItems = []; for (final downloadedParent in _downloadsHelper.downloadedParents) { if (baseItems.length >= limit) break; - if (downloadedParent.item.type == tabContentType.itemType()) { + if (downloadedParent.item.type == itemId.contentType.itemType()) { baseItems.add(downloadedParent.item); } } @@ -63,28 +60,28 @@ class AndroidAutoHelper { if (downloadedParent != null) { final downloadedItems = [for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]; // only sort items if we are not playing them - return _isPlayable(tabContentType) ? downloadedItems : _sortItems(downloadedItems, sortBy, sortOrder); + return _isPlayable(itemId.contentType) ? downloadedItems : _sortItems(downloadedItems, sortBy, sortOrder); } } // fetch the online version if we can't get offline version // select the item type that each parent holds - final includeItemTypes = itemId.parentType == MediaItemParentType.collection // if parentId is -1, we are browsing a root library. e.g. browsing the list of all albums or artists - ? (tabContentType == TabContentType.albums ? TabContentType.songs.itemType() // get an album's songs - : tabContentType == TabContentType.artists ? TabContentType.albums.itemType() // get an artist's albums - : tabContentType == TabContentType.playlists ? TabContentType.songs.itemType() // get a playlist's songs - : tabContentType == TabContentType.genres ? TabContentType.albums.itemType() // get a genre's albums + final includeItemTypes = itemId.parentType == MediaItemParentType.collection // if we are browsing a root library. e.g. browsing the list of all albums or artists + ? (itemId.contentType == TabContentType.albums ? TabContentType.songs.itemType() // get an album's songs + : itemId.contentType == TabContentType.artists ? TabContentType.albums.itemType() // get an artist's albums + : itemId.contentType == TabContentType.playlists ? TabContentType.songs.itemType() // get a playlist's songs + : itemId.contentType == TabContentType.genres ? TabContentType.albums.itemType() // get a genre's albums : TabContentType.songs.itemType() ) // if we don't have one of these categories, we are probably dealing with stray songs - : tabContentType.itemType(); // get the root library + : itemId.contentType.itemType(); // get the root library // if parent id is defined, use that to get items. // otherwise, use the current view as fallback to ensure we get the correct items. final parentItem = itemId.parentType == MediaItemParentType.collection - ? BaseItemDto(id: itemId.itemId!, type: tabContentType.itemType()) + ? BaseItemDto(id: itemId.itemId!, type: itemId.contentType.itemType()) : _finampUserHelper.currentUser?.currentView; - final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(tabContentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, isGenres: tabContentType == TabContentType.genres, limit: limit); + final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(itemId.contentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, isGenres: itemId.contentType == TabContentType.genres, limit: limit); return items ?? []; } @@ -269,10 +266,9 @@ class AndroidAutoHelper { Future playFromMediaId(MediaItemId itemId) async { final audioServiceHelper = GetIt.instance(); - final tabContentType = TabContentType.values.firstWhere((e) => e == itemId.contentType); // shouldn't happen, but just in case - if (!_isPlayable(tabContentType)) { + if (!_isPlayable(itemId.contentType)) { _androidAutoHelperLogger.warning("Tried to play from media id with non-playable item type ${itemId.parentType.name}"); return; } @@ -289,7 +285,7 @@ class AndroidAutoHelper { final parentItem = await getParentFromId(itemId.itemId!); // start instant mix for artists - if (tabContentType == TabContentType.artists) { + if (itemId.contentType == TabContentType.artists) { // we don't show artists in offline mode, and parent item can't be null for mix // this shouldn't happen, but just in case if (FinampSettingsHelper.finampSettings.isOffline || parentItem == null) { @@ -304,7 +300,7 @@ class AndroidAutoHelper { // queue service should be initialized by time we get here final queueService = GetIt.instance(); await queueService.startPlayback(items: parentBaseItems, source: QueueItemSource( - type: tabContentType == TabContentType.playlists + type: itemId.contentType == TabContentType.playlists ? QueueItemSourceType.playlist : QueueItemSourceType.album, name: QueueItemSourceName( From 32d213f8f9759eb2de9c261dd563dde7c6c20af0 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sat, 17 Feb 2024 12:41:40 +0100 Subject: [PATCH 22/42] shuffle all for empty search query ("play some music") --- lib/services/android_auto_helper.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 20b7209c0..458eb5f67 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -166,6 +166,10 @@ class AndroidAutoHelper { final finampUserHelper = GetIt.instance(); final audioServiceHelper = GetIt.instance(); final queueService = GetIt.instance(); + + if (query.isEmpty) { + return await shuffleAllSongs(); + } String itemType = "Audio"; String? alternativeQuery; @@ -255,6 +259,16 @@ class AndroidAutoHelper { } } + Future shuffleAllSongs() async { + final audioServiceHelper = GetIt.instance(); + + try { + await audioServiceHelper.shuffleAll(FinampSettingsHelper.finampSettings.isFavourite); + } catch (err) { + _androidAutoHelperLogger.severe("Error while shuffling all songs:", err); + } + } + Future> getMediaItems(MediaItemId itemId) async { return [ for (final item in await getBaseItems(itemId)) await _convertToMediaItem(item: item, parentType: MediaItemParentType.collection, parentId: item.parentId) ]; } From a03199f268b568430748b45fc8be7a37549792f8 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Mon, 19 Feb 2024 20:55:00 +0100 Subject: [PATCH 23/42] increase timeout for http requests --- lib/services/jellyfin_api.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart index 59844fc62..422d66db5 100644 --- a/lib/services/jellyfin_api.dart +++ b/lib/services/jellyfin_api.dart @@ -407,7 +407,7 @@ abstract class JellyfinApi extends ChopperService { final client = ChopperClient( client: http.IOClient( HttpClient() - ..connectionTimeout = const Duration(seconds: 5) // if we don't get a response by then, it's probably not worth it to wait any longer. this prevents the server connection test from taking too long + ..connectionTimeout = const Duration(seconds: 8) // if we don't get a response by then, it's probably not worth it to wait any longer. this prevents the server connection test from taking too long ), // The first part of the URL is now here services: [ From 1febde3acafb80d4924cdadc7384a5e95eee4dc0 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Fri, 8 Mar 2024 13:05:35 -0500 Subject: [PATCH 24/42] Re-implement MediaItemContentProvider in Kotlin --- .../com/unicornsonlsd/finamp/MainActivity.kt | 9 +-- .../finamp/MediaItemContentProvider.kt | 81 ++++++++++++++++++- lib/main.dart | 9 +-- lib/services/mediaitem_content_provider.dart | 67 --------------- pubspec.lock | 8 -- pubspec.yaml | 1 - 6 files changed, 79 insertions(+), 96 deletions(-) delete mode 100644 lib/services/mediaitem_content_provider.dart diff --git a/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt index 9a7d0cd8e..4eafd82c0 100644 --- a/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt +++ b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt @@ -1,13 +1,6 @@ package com.unicornsonlsd.finamp -import android.content.Context -import com.nt4f04und.android_content_provider.AndroidContentProvider import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine class MainActivity: FlutterActivity() { - override fun provideFlutterEngine(context: Context): FlutterEngine? { - return AndroidContentProvider.getFlutterEngineGroup(this) - .createAndRunDefaultEngine(this) - } -} +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MediaItemContentProvider.kt b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MediaItemContentProvider.kt index 158fa10e8..6e182bf46 100644 --- a/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MediaItemContentProvider.kt +++ b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MediaItemContentProvider.kt @@ -1,8 +1,81 @@ package com.unicornsonlsd.finamp -import com.nt4f04und.android_content_provider.AndroidContentProvider +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.util.LruCache +import java.io.File +import java.io.FileOutputStream +import java.net.URL -class MediaItemContentProvider : AndroidContentProvider() { - override val authority: String = "com.unicornsonlsd.finamp.MediaItemContentProvider" - override val entrypointName = "mediaItemContentProviderEntrypoint" +class MediaItemContentProvider : ContentProvider() { + + private lateinit var memoryCache : LruCache + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + if (uri.fragment != null) { + // we store the original scheme://host in fragment since it should be unused + val origin = Uri.parse(uri.fragment) + val fixedUri = uri.buildUpon().fragment(null).scheme(origin.scheme).authority(origin.authority).toString() + + // check if we already cached the image + val bytes = memoryCache.get(fixedUri) + if (bytes != null) { + return openPipeHelper(uri, "application/octet-stream", null, bytes) {output, _, _, _, b -> + FileOutputStream(output.fileDescriptor).write(b) + } + } + + val response = URL(fixedUri).readBytes() + memoryCache.put(fixedUri, response) + return openPipeHelper(uri, "application/octet-stream", null, response) {output, _, _, _, b -> + FileOutputStream(output.fileDescriptor).write(b) + } + } + + // this means it's a local image (downloaded or placeholder art) + return ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_ONLY) + } + + override fun onCreate(): Boolean { + // Get max available VM memory, exceeding this amount will throw an + // OutOfMemory exception. Stored in kilobytes as LruCache takes an + // int in its constructor. + val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() + + // Use 1/8th of the available memory for this memory cache. + val cacheSize = maxMemory / 8 + memoryCache = object : LruCache(cacheSize) { + + override fun sizeOf(key: String, value: ByteArray): Int { + // The cache size will be measured in kilobytes rather than + // number of items. + return value.size / 1024 + } + } + + return true + } + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? { + return null + } + + override fun getType(uri: Uri): String? { + return null + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + return null + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + return 0 + } + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { + return 0 + } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 0b8a436da..5e4228a33 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,10 +13,8 @@ import 'package:finamp/color_schemes.g.dart'; import 'package:finamp/screens/interaction_settings_screen.dart'; import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:finamp/services/finamp_user_helper.dart'; -import 'package:finamp/services/mediaitem_content_provider.dart'; import 'package:finamp/services/playback_history_service.dart'; import 'package:finamp/services/queue_service.dart'; -import 'package:finamp/color_schemes.g.dart'; import 'package:finamp/services/offline_listen_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -481,9 +479,4 @@ class _DummyCallback { IsolateNameServer.lookupPortByName('downloader_send_port'); send!.send([id, status, progress]); } -} - -@pragma('vm:entry-point') -void mediaItemContentProviderEntrypoint() { - MediaItemContentProvider('com.unicornsonlsd.finamp.MediaItemContentProvider'); -} +} \ No newline at end of file diff --git a/lib/services/mediaitem_content_provider.dart b/lib/services/mediaitem_content_provider.dart deleted file mode 100644 index c385d4304..000000000 --- a/lib/services/mediaitem_content_provider.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:android_content_provider/android_content_provider.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:logging/logging.dart'; - -class MediaItemContentProvider extends AndroidContentProvider { - static final _mediaItemContentProviderLogger = Logger("MediaItemContentProvider"); - - late final DefaultCacheManager _cacheManager; - - MediaItemContentProvider(String authority) : super(authority) { - WidgetsFlutterBinding.ensureInitialized(); // needed for cache manager - _cacheManager = DefaultCacheManager(); - } - - @override - Future openFile(String uri, String mode) async { - final parsedUri = Uri.tryParse(uri); - if (parsedUri == null) { - _mediaItemContentProviderLogger.severe("Unknown uri in media item content provider: $uri"); - return uri; - } - - // we store the original scheme://host in fragment since it should be unused - if (parsedUri.hasFragment) { - final origin = Uri.parse(parsedUri.fragment); - final fixedUri = parsedUri.replace(scheme: origin.scheme, host: origin.host).removeFragment().toString(); - try { - final imageFile = await _cacheManager.getSingleFile(fixedUri, key: fixedUri); - return imageFile.path; - } catch (e) { - _mediaItemContentProviderLogger.severe("Failed resolving uri in media item content provider: $fixedUri"); - return fixedUri; - } - } - - // this means it's a local image (downloaded or placeholder art) - return Uri.parse(uri).path; - } - - // Unused - - @override - Future delete(String uri, String? selection, List? selectionArgs) { - throw UnimplementedError(); - } - - @override - Future getType(String uri) { - return Future.value(null); - } - - @override - Future insert(String uri, ContentValues? values) { - throw UnimplementedError(); - } - - @override - Future query(String uri, List? projection, String? selection, List? selectionArgs, String? sortOrder) { - throw UnimplementedError(); - } - - @override - Future update(String uri, ContentValues? values, String? selection, List? selectionArgs) { - throw UnimplementedError(); - } -} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 6678871ea..95011b6e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,14 +17,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.2.0" - android_content_provider: - dependency: "direct main" - description: - name: android_content_provider - sha256: "847db497f837334c11d72d4a71b1c496562f0fd7c97710315e0ad5ee9cbfe593" - url: "https://pub.dev" - source: hosted - version: "0.4.1" android_id: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b2bd15748..70108aae2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,7 +83,6 @@ dependencies: flutter_vibrate: ^1.3.0 flutter_downloader: ^1.11.5 mini_music_visualizer: ^1.0.2 - android_content_provider: ^0.4.1 balanced_text: ^0.0.3 flutter_cache_manager: ^3.3.1 flutter_to_airplay: ^2.0.4 From 4e701185a3511c79c81d826154316ebcee5f4000 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sat, 9 Mar 2024 15:23:51 +0100 Subject: [PATCH 25/42] improve search using metadata where possible --- lib/services/android_auto_helper.dart | 93 +++++++++++++++---- .../music_player_background_task.dart | 23 ++++- pubspec.lock | 48 ++++++++-- 3 files changed, 134 insertions(+), 30 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 458eb5f67..2730ffb4d 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -15,6 +15,14 @@ import 'finamp_settings_helper.dart'; import 'queue_service.dart'; import 'audio_service_helper.dart'; +class AndroidAutoSearchQuery { + String query; + Map? extras; + + AndroidAutoSearchQuery(this.query, this.extras); + +} + class AndroidAutoHelper { static final _androidAutoHelperLogger = Logger("AndroidAutoHelper"); @@ -23,6 +31,15 @@ class AndroidAutoHelper { final _jellyfinApiHelper = GetIt.instance(); final _downloadsHelper = GetIt.instance(); + // actively remembered search query because Android Auto doesn't give us the extras during a regular search (e.g. clicking the "Search Results" button on the player screen after a voice search) + AndroidAutoSearchQuery? _lastSearchQuery; + + void setLastSearchQuery(AndroidAutoSearchQuery? searchQuery) { + _lastSearchQuery = searchQuery; + } + + AndroidAutoSearchQuery? get lastSearchQuery => _lastSearchQuery; + Future getParentFromId(String parentId) async { final downloadedParent = _downloadsHelper.getDownloadedParent(parentId)?.item; if (downloadedParent != null) { @@ -99,7 +116,7 @@ class AndroidAutoHelper { } } - Future> searchItems(String query, Map? extras) async { + Future> searchItems(AndroidAutoSearchQuery searchQuery) async { final jellyfinApiHelper = GetIt.instance(); final finampUserHelper = GetIt.instance(); @@ -107,7 +124,7 @@ class AndroidAutoHelper { List? searchResult; - if (extras?["android.intent.extra.title"] != null) { + if (searchQuery.extras != null && searchQuery.extras?["android.intent.extra.title"] != null) { // search for exact query first, then search for adjusted query // sometimes Google's adjustment might not be what we want, but sometimes it actually helps List? searchResultExactQuery; @@ -116,7 +133,7 @@ class AndroidAutoHelper { searchResultExactQuery = await jellyfinApiHelper.getItems( parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: "Audio", - searchTerm: query.trim(), + searchTerm: searchQuery.query.trim(), isGenres: false, startIndex: 0, limit: 7, @@ -128,7 +145,7 @@ class AndroidAutoHelper { searchResultAdjustedQuery = await jellyfinApiHelper.getItems( parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: "Audio", - searchTerm: extras!["android.intent.extra.title"].trim(), + searchTerm: searchQuery.extras!["android.intent.extra.title"].trim(), isGenres: false, startIndex: 0, limit: (searchResultExactQuery != null && searchResultExactQuery.isNotEmpty) ? 13 : 20, @@ -143,48 +160,84 @@ class AndroidAutoHelper { searchResult = await jellyfinApiHelper.getItems( parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: "Audio", - searchTerm: query.trim(), + searchTerm: searchQuery.query.trim(), isGenres: false, startIndex: 0, limit: 20, ); } + final List filteredSearchResults = []; + // filter out duplicates + for (final item in searchResult ?? []) { + if (!filteredSearchResults.any((element) => element.id == item.id)) { + filteredSearchResults.add(item); + } + } + if (searchResult != null && searchResult.isEmpty) { - _androidAutoHelperLogger.warning("No search results found for query: $query (extras: $extras)"); + _androidAutoHelperLogger.warning("No search results found for query: ${searchQuery.query} (extras: ${searchQuery.extras})"); } - return [ for (final item in searchResult!) await _convertToMediaItem(item: item, parentType: MediaItemParentType.instantMix, parentId: item.parentId) ]; + int calculateMatchQuality(BaseItemDto item, AndroidAutoSearchQuery searchQuery) { + final title = item.name ?? ""; + + final wantedTitle = searchQuery.extras?["android.intent.extra.title"]; + final wantedArtist = searchQuery.extras?["android.intent.extra.artist"]; + + if ( + wantedArtist != null && + item.albumArtists?.any((artist) => searchQuery.extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true + ) { + return 1; + } else if (title == wantedTitle) { + // Title matches, normal priority + return 0; + } else { + // No exact match, lower priority + return -1; + } + } + + // sort items based on match quality with extras + filteredSearchResults.sort((a, b) { + final aMatchQuality = calculateMatchQuality(a, searchQuery); + final bMatchQuality = calculateMatchQuality(b, searchQuery); + return bMatchQuality.compareTo(aMatchQuality); + }); + + return [ for (final item in filteredSearchResults) await _convertToMediaItem(item: item, parentType: MediaItemParentType.instantMix, parentId: item.parentId) ]; } catch (err) { _androidAutoHelperLogger.severe("Error while searching:", err); return []; } } - Future playFromSearch(String query, Map? extras) async { + Future playFromSearch(AndroidAutoSearchQuery searchQuery) async { final jellyfinApiHelper = GetIt.instance(); final finampUserHelper = GetIt.instance(); final audioServiceHelper = GetIt.instance(); final queueService = GetIt.instance(); - if (query.isEmpty) { + if (searchQuery.query.isEmpty) { return await shuffleAllSongs(); } String itemType = "Audio"; String? alternativeQuery; - if (extras?["android.intent.extra.album"] != null && extras?["android.intent.extra.artist"] != null && extras?["android.intent.extra.title"] != null) { + if (searchQuery.extras?["android.intent.extra.album"] != null && searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] != null) { // if all metadata is provided, search for song itemType = "Audio"; - alternativeQuery = extras?["android.intent.extra.title"]; - } else if (extras?["android.intent.extra.album"] != null && extras?["android.intent.extra.artist"] != null && extras?["android.intent.extra.title"] == null) { + alternativeQuery = searchQuery.extras?["android.intent.extra.title"]; + } else if (searchQuery.extras?["android.intent.extra.album"] != null && searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] == null) { // if only album is provided, search for album itemType = "MusicAlbum"; - alternativeQuery = extras?["android.intent.extra.album"]; - } else if (extras?["android.intent.extra.artist"] != null && extras?["android.intent.extra.title"] == null) { + alternativeQuery = searchQuery.extras?["android.intent.extra.album"]; + } else if (searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] == null) { // if only artist is provided, search for artist itemType = "MusicArtist"; + alternativeQuery = searchQuery.extras?["android.intent.extra.artist"]; alternativeQuery = extras?["android.intent.extra.artist"]; } @@ -194,7 +247,7 @@ class AndroidAutoHelper { List? searchResult = await jellyfinApiHelper.getItems( parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: itemType, - searchTerm: alternativeQuery?.trim() ?? query.trim(), + searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), isGenres: false, startIndex: 0, limit: 25, // get more than the first result so we can filter using additional metadata @@ -222,10 +275,10 @@ class AndroidAutoHelper { } final selectedResult = searchResult.firstWhere((element) { - if (itemType == "Audio" && extras?["android.intent.extra.artist"] != null) { - return element.albumArtists?.any((artist) => extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true; - } else if (itemType == "MusicAlbum" && extras?["android.intent.extra.artist"] != null) { - return element.albumArtists?.any((artist) => extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true; + if (itemType == "Audio" && searchQuery.extras?["android.intent.extra.artist"] != null) { + return element.albumArtists?.any((artist) => searchQuery.extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true; + } else if (itemType == "MusicAlbum" && searchQuery.extras?["android.intent.extra.artist"] != null) { + return element.albumArtists?.any((artist) => searchQuery.extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true; } else { return false; } @@ -235,7 +288,7 @@ class AndroidAutoHelper { _androidAutoHelperLogger.info("Playing from search: ${selectedResult.name}"); if (itemType == "MusicAlbum") { - final album = await jellyfinApiHelper.getItemById(selectedResult.id); + final album = await _jellyfinApiHelper.getItemById(selectedResult.id); final items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: "Audio", isGenres: false, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); _androidAutoHelperLogger.info("Playing album: ${album.name} (${items?.length} songs)"); diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 8adb04475..0d518b36d 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -452,8 +452,25 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { @override Future> search(String query, [Map? extras]) async { _audioServiceBackgroundTaskLogger.info("search: $query ; extras: $extras"); + + final previousItemTitle = _androidAutoHelper.lastSearchQuery?.extras?["android.intent.extra.title"]; + + final currentSearchQuery = AndroidAutoSearchQuery(query, extras); + + if (previousItemTitle != null) { + // when voice searching for a song with title + artist, Android Auto / Google Assistant combines the title and artist into a single query, with no way to differentiate them + // so we try to instead use the title provided in the extras right after the voice search, and just search for that + if (query.contains(previousItemTitle)) { + // if the the title is fully contained in the query, we can assume that the user clicked on the "Search Results" button on the player screen + currentSearchQuery.query = previousItemTitle; + currentSearchQuery.extras = _androidAutoHelper.lastSearchQuery?.extras; + } else { + // otherwise, we assume they're searching for something else, and discard the previous search query + _androidAutoHelper.setLastSearchQuery(null); + } + } - return await _androidAutoHelper.searchItems(query, extras); + return await _androidAutoHelper.searchItems(currentSearchQuery); } @@ -461,7 +478,9 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { @override Future playFromSearch(String query, [Map? extras]) async { _audioServiceBackgroundTaskLogger.info("playFromSearch: $query ; extras: $extras"); - await _androidAutoHelper.playFromSearch(query, extras); + final searchQuery = AndroidAutoSearchQuery(query, extras); + _androidAutoHelper.setLastSearchQuery(searchQuery); + await _androidAutoHelper.playFromSearch(searchQuery); } @override diff --git a/pubspec.lock b/pubspec.lock index 95011b6e8..cf63511e0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -622,6 +622,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.9" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + url: "https://pub.dev" + source: hosted + version: "2.0.1" lints: dependency: transitive description: @@ -651,26 +675,26 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.11.0" mime: dependency: transitive description: @@ -740,10 +764,10 @@ packages: dependency: "direct main" description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_provider: dependency: "direct main" description: @@ -1149,6 +1173,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + url: "https://pub.dev" + source: hosted + version: "13.0.0" watcher: dependency: transitive description: From d8a11e24f11fe5300a5f98b27c1bd136205e731d Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sat, 9 Mar 2024 15:28:20 +0100 Subject: [PATCH 26/42] prefer playlists for voice search --- lib/services/android_auto_helper.dart | 38 ++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 2730ffb4d..fc741b699 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -225,6 +225,7 @@ class AndroidAutoHelper { String itemType = "Audio"; String? alternativeQuery; + bool searchForPlaylists = false; if (searchQuery.extras?["android.intent.extra.album"] != null && searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] != null) { // if all metadata is provided, search for song @@ -238,10 +239,41 @@ class AndroidAutoHelper { // if only artist is provided, search for artist itemType = "MusicArtist"; alternativeQuery = searchQuery.extras?["android.intent.extra.artist"]; - alternativeQuery = extras?["android.intent.extra.artist"]; - } + } else { + // if no metadata is provided, search for song *and* playlists, preferring playlists + searchForPlaylists = true; + } + + _androidAutoHelperLogger.info("Searching for: $itemType that matches query '${alternativeQuery ?? searchQuery.query}'${searchForPlaylists ? ", including (and preferring) playlists" : ""}"); - _androidAutoHelperLogger.info("Searching for: $itemType that matches query '${alternativeQuery ?? query}'"); + try { + List? searchResult = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: "Playlist", + searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), + isGenres: false, + startIndex: 0, + limit: 1, + ); + + final playlist = searchResult![0]; + final items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: "Audio", isGenres: false, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + _androidAutoHelperLogger.info("Playing playlist: ${playlist.name} (${items?.length} songs)"); + + queueService.startPlayback(items: items ?? [], source: QueueItemSource( + type: QueueItemSourceType.playlist, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: playlist.name), + id: playlist.id, + item: playlist, + ), + order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? + ); + + } catch (e) { + _androidAutoHelperLogger.warning("Couldn't search for playlists:", e); + } try { List? searchResult = await jellyfinApiHelper.getItems( From be80fb6e7c9a94285db3800fb1d064daf16c7bc7 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sat, 9 Mar 2024 17:29:56 +0100 Subject: [PATCH 27/42] make voice search artist match case insensitive --- lib/services/android_auto_helper.dart | 79 ++++++++++++--------------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index fc741b699..e16baf477 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -1,14 +1,13 @@ import 'dart:math'; import 'package:audio_service/audio_service.dart'; -import 'package:collection/collection.dart'; +import 'package:finamp/services/downloads_service.dart'; import 'package:get_it/get_it.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; -import 'downloads_helper.dart'; import 'finamp_user_helper.dart'; import 'jellyfin_api_helper.dart'; import 'finamp_settings_helper.dart'; @@ -29,7 +28,7 @@ class AndroidAutoHelper { final _finampUserHelper = GetIt.instance(); final _jellyfinApiHelper = GetIt.instance(); - final _downloadsHelper = GetIt.instance(); + final _downloadService = GetIt.instance(); // actively remembered search query because Android Auto doesn't give us the extras during a regular search (e.g. clicking the "Search Results" button on the player screen after a voice search) AndroidAutoSearchQuery? _lastSearchQuery; @@ -41,9 +40,9 @@ class AndroidAutoHelper { AndroidAutoSearchQuery? get lastSearchQuery => _lastSearchQuery; Future getParentFromId(String parentId) async { - final downloadedParent = _downloadsHelper.getDownloadedParent(parentId)?.item; + final downloadedParent = await _downloadService.getCollectionInfo(id: parentId); if (downloadedParent != null) { - return downloadedParent; + return downloadedParent.baseItem; } else if (FinampSettingsHelper.finampSettings.isOffline) { return null; } @@ -62,10 +61,10 @@ class AndroidAutoHelper { // if we are in offline mode and in root parent/collection, display all matching downloaded parents if (FinampSettingsHelper.finampSettings.isOffline && itemId.parentType == MediaItemParentType.rootCollection) { List baseItems = []; - for (final downloadedParent in _downloadsHelper.downloadedParents) { + for (final downloadedParent in await _downloadService.getAllCollections()) { if (baseItems.length >= limit) break; - if (downloadedParent.item.type == itemId.contentType.itemType()) { - baseItems.add(downloadedParent.item); + if (downloadedParent.baseItem != null && downloadedParent.baseItemType == itemId.contentType.itemType) { + baseItems.add(downloadedParent.baseItem!); } } return _sortItems(baseItems, sortBy, sortOrder); @@ -73,9 +72,10 @@ class AndroidAutoHelper { // try to use downloaded parent first if (itemId.parentType == MediaItemParentType.collection) { - var downloadedParent = _downloadsHelper.getDownloadedParent(itemId.itemId!); - if (downloadedParent != null) { - final downloadedItems = [for (final child in downloadedParent.downloadedChildren.values.whereIndexed((i, e) => i < limit)) child]; + var downloadedParent = await _downloadService.getCollectionInfo(id: itemId.itemId); + if (downloadedParent != null && downloadedParent.baseItem != null) { + final downloadedItems = await _downloadService.getCollectionSongs(downloadedParent.baseItem!); + //TODO enforce page limit // only sort items if we are not playing them return _isPlayable(itemId.contentType) ? downloadedItems : _sortItems(downloadedItems, sortBy, sortOrder); } @@ -85,20 +85,20 @@ class AndroidAutoHelper { // select the item type that each parent holds final includeItemTypes = itemId.parentType == MediaItemParentType.collection // if we are browsing a root library. e.g. browsing the list of all albums or artists - ? (itemId.contentType == TabContentType.albums ? TabContentType.songs.itemType() // get an album's songs - : itemId.contentType == TabContentType.artists ? TabContentType.albums.itemType() // get an artist's albums - : itemId.contentType == TabContentType.playlists ? TabContentType.songs.itemType() // get a playlist's songs - : itemId.contentType == TabContentType.genres ? TabContentType.albums.itemType() // get a genre's albums - : TabContentType.songs.itemType() ) // if we don't have one of these categories, we are probably dealing with stray songs - : itemId.contentType.itemType(); // get the root library + ? (itemId.contentType == TabContentType.albums ? TabContentType.songs.itemType.name // get an album's songs + : itemId.contentType == TabContentType.artists ? TabContentType.albums.itemType.name // get an artist's albums + : itemId.contentType == TabContentType.playlists ? TabContentType.songs.itemType.name // get a playlist's songs + : itemId.contentType == TabContentType.genres ? TabContentType.albums.itemType.name // get a genre's albums + : TabContentType.songs.itemType.name ) // if we don't have one of these categories, we are probably dealing with stray songs + : itemId.contentType.itemType.name; // get the root library // if parent id is defined, use that to get items. // otherwise, use the current view as fallback to ensure we get the correct items. final parentItem = itemId.parentType == MediaItemParentType.collection - ? BaseItemDto(id: itemId.itemId!, type: itemId.contentType.itemType()) + ? BaseItemDto(id: itemId.itemId!, type: itemId.contentType.itemType.name) : _finampUserHelper.currentUser?.currentView; - final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(itemId.contentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, isGenres: itemId.contentType == TabContentType.genres, limit: limit); + final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(itemId.contentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, limit: limit); return items ?? []; } @@ -134,7 +134,6 @@ class AndroidAutoHelper { parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: "Audio", searchTerm: searchQuery.query.trim(), - isGenres: false, startIndex: 0, limit: 7, ); @@ -146,7 +145,6 @@ class AndroidAutoHelper { parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: "Audio", searchTerm: searchQuery.extras!["android.intent.extra.title"].trim(), - isGenres: false, startIndex: 0, limit: (searchResultExactQuery != null && searchResultExactQuery.isNotEmpty) ? 13 : 20, ); @@ -161,7 +159,6 @@ class AndroidAutoHelper { parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: "Audio", searchTerm: searchQuery.query.trim(), - isGenres: false, startIndex: 0, limit: 20, ); @@ -187,7 +184,7 @@ class AndroidAutoHelper { if ( wantedArtist != null && - item.albumArtists?.any((artist) => searchQuery.extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true + (item.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (searchQuery.extras?["android.intent.extra.artist"]?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false) ) { return 1; } else if (title == wantedTitle) { @@ -251,16 +248,15 @@ class AndroidAutoHelper { parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: "Playlist", searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), - isGenres: false, startIndex: 0, limit: 1, ); final playlist = searchResult![0]; - final items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: "Audio", isGenres: false, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + final items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: "Audio", sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); _androidAutoHelperLogger.info("Playing playlist: ${playlist.name} (${items?.length} songs)"); - queueService.startPlayback(items: items ?? [], source: QueueItemSource( + await queueService.startPlayback(items: items ?? [], source: QueueItemSource( type: QueueItemSourceType.playlist, name: QueueItemSourceName( type: QueueItemSourceNameType.preTranslated, @@ -280,7 +276,6 @@ class AndroidAutoHelper { parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: itemType, searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), - isGenres: false, startIndex: 0, limit: 25, // get more than the first result so we can filter using additional metadata ); @@ -294,7 +289,6 @@ class AndroidAutoHelper { parentItem: finampUserHelper.currentUser?.currentView, includeItemTypes: itemType, searchTerm: alternativeQuery.trim(), - isGenres: false, startIndex: 0, limit: 25, // get more than the first result so we can filter using additional metadata ); @@ -308,9 +302,9 @@ class AndroidAutoHelper { final selectedResult = searchResult.firstWhere((element) { if (itemType == "Audio" && searchQuery.extras?["android.intent.extra.artist"] != null) { - return element.albumArtists?.any((artist) => searchQuery.extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true; + return element.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (searchQuery.extras?["android.intent.extra.artist"]?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false; } else if (itemType == "MusicAlbum" && searchQuery.extras?["android.intent.extra.artist"] != null) { - return element.albumArtists?.any((artist) => searchQuery.extras?["android.intent.extra.artist"]?.contains(artist.name) == true) == true; + return element.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (searchQuery.extras?["android.intent.extra.artist"]?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false; } else { return false; } @@ -321,10 +315,10 @@ class AndroidAutoHelper { if (itemType == "MusicAlbum") { final album = await _jellyfinApiHelper.getItemById(selectedResult.id); - final items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: "Audio", isGenres: false, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + final items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: "Audio", sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); _androidAutoHelperLogger.info("Playing album: ${album.name} (${items?.length} songs)"); - queueService.startPlayback(items: items ?? [], source: QueueItemSource( + await queueService.startPlayback(items: items ?? [], source: QueueItemSource( type: QueueItemSourceType.album, name: QueueItemSourceName( type: QueueItemSourceNameType.preTranslated, @@ -348,7 +342,7 @@ class AndroidAutoHelper { final audioServiceHelper = GetIt.instance(); try { - await audioServiceHelper.shuffleAll(FinampSettingsHelper.finampSettings.isFavourite); + await audioServiceHelper.shuffleAll(FinampSettingsHelper.finampSettings.onlyShowFavourite); } catch (err) { _androidAutoHelperLogger.severe("Error while shuffling all songs:", err); } @@ -494,17 +488,18 @@ class AndroidAutoHelper { itemId: item.id, ); - final downloadedSong = _downloadsHelper.getDownloadedSong(item.id); - final isDownloaded = downloadedSong == null - ? false - : await _downloadsHelper.verifyDownloadedSong(downloadedSong); - - var downloadedImage = _downloadsHelper.getDownloadedImage(item); + final downloadedSong = await _downloadService.getSongDownload(item: item); + DownloadItem? downloadedImage; + try { + downloadedImage = await _downloadService.getImageDownload(item: item); + } catch (e) { + _androidAutoHelperLogger.warning("Couldn't get the offline image for track '${item.name}' because it's missing a blurhash"); + } Uri? artUri; // replace with content uri or jellyfin api uri if (downloadedImage != null) { - artUri = downloadedImage.file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); + artUri = downloadedImage.file?.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); } else if (!FinampSettingsHelper.finampSettings.isOffline) { artUri = _jellyfinApiHelper.getImageUrl(item: item); // try to get image file (Android Automotive needs this) @@ -539,9 +534,7 @@ class AndroidAutoHelper { extras: { "itemJson": item.toJson(), "shouldTranscode": FinampSettingsHelper.finampSettings.shouldTranscode, - "downloadedSongJson": isDownloaded - ? (_downloadsHelper.getDownloadedSong(item.id))!.toJson() - : null, + "downloadedSongPath": downloadedSong?.file?.path, "isOffline": FinampSettingsHelper.finampSettings.isOffline, }, // Jellyfin returns microseconds * 10 for some reason From ea6ec93d258fe1ffc1cd6b6ca403ea42309847e1 Mon Sep 17 00:00:00 2001 From: puff <33184334+puff@users.noreply.github.com> Date: Sat, 9 Mar 2024 23:49:01 -0500 Subject: [PATCH 28/42] Use correct item type id in Android Auto --- lib/services/android_auto_helper.dart | 54 ++++++++------- .../music_player_background_task.dart | 68 ++++++++++--------- 2 files changed, 67 insertions(+), 55 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index e16baf477..d26bf357c 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -51,6 +51,11 @@ class AndroidAutoHelper { } Future> getBaseItems(MediaItemId itemId) async { + // offline mode only supports albums and playlists for now (no offline instant mix for others yet) + if (FinampSettingsHelper.finampSettings.isOffline && (itemId.contentType == TabContentType.artists || itemId.contentType == TabContentType.genres)) { + return []; + } + // limit amount so it doesn't crash on large libraries // TODO: add pagination const limit = 100; @@ -75,7 +80,10 @@ class AndroidAutoHelper { var downloadedParent = await _downloadService.getCollectionInfo(id: itemId.itemId); if (downloadedParent != null && downloadedParent.baseItem != null) { final downloadedItems = await _downloadService.getCollectionSongs(downloadedParent.baseItem!); - //TODO enforce page limit + if (downloadedItems.length >= limit) { + downloadedItems.removeRange(limit, downloadedItems.length - 1); + } + // only sort items if we are not playing them return _isPlayable(itemId.contentType) ? downloadedItems : _sortItems(downloadedItems, sortBy, sortOrder); } @@ -85,17 +93,17 @@ class AndroidAutoHelper { // select the item type that each parent holds final includeItemTypes = itemId.parentType == MediaItemParentType.collection // if we are browsing a root library. e.g. browsing the list of all albums or artists - ? (itemId.contentType == TabContentType.albums ? TabContentType.songs.itemType.name // get an album's songs - : itemId.contentType == TabContentType.artists ? TabContentType.albums.itemType.name // get an artist's albums - : itemId.contentType == TabContentType.playlists ? TabContentType.songs.itemType.name // get a playlist's songs - : itemId.contentType == TabContentType.genres ? TabContentType.albums.itemType.name // get a genre's albums - : TabContentType.songs.itemType.name ) // if we don't have one of these categories, we are probably dealing with stray songs - : itemId.contentType.itemType.name; // get the root library + ? (itemId.contentType == TabContentType.albums ? TabContentType.songs.itemType.idString // get an album's songs + : itemId.contentType == TabContentType.artists ? TabContentType.albums.itemType.idString // get an artist's albums + : itemId.contentType == TabContentType.playlists ? TabContentType.songs.itemType.idString // get a playlist's songs + : itemId.contentType == TabContentType.genres ? TabContentType.albums.itemType.idString // get a genre's albums + : TabContentType.songs.itemType.idString ) // if we don't have one of these categories, we are probably dealing with stray songs + : itemId.contentType.itemType.idString; // get the root library // if parent id is defined, use that to get items. // otherwise, use the current view as fallback to ensure we get the correct items. final parentItem = itemId.parentType == MediaItemParentType.collection - ? BaseItemDto(id: itemId.itemId!, type: itemId.contentType.itemType.name) + ? BaseItemDto(id: itemId.itemId!, type: itemId.contentType.itemType.idString) : _finampUserHelper.currentUser?.currentView; final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(itemId.contentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, limit: limit); @@ -132,7 +140,7 @@ class AndroidAutoHelper { try { searchResultExactQuery = await jellyfinApiHelper.getItems( parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: "Audio", + includeItemTypes: TabContentType.songs.itemType.idString, searchTerm: searchQuery.query.trim(), startIndex: 0, limit: 7, @@ -143,7 +151,7 @@ class AndroidAutoHelper { try { searchResultAdjustedQuery = await jellyfinApiHelper.getItems( parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: "Audio", + includeItemTypes: TabContentType.songs.itemType.idString, searchTerm: searchQuery.extras!["android.intent.extra.title"].trim(), startIndex: 0, limit: (searchResultExactQuery != null && searchResultExactQuery.isNotEmpty) ? 13 : 20, @@ -157,7 +165,7 @@ class AndroidAutoHelper { } else { searchResult = await jellyfinApiHelper.getItems( parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: "Audio", + includeItemTypes: TabContentType.songs.itemType.idString, searchTerm: searchQuery.query.trim(), startIndex: 0, limit: 20, @@ -220,21 +228,21 @@ class AndroidAutoHelper { return await shuffleAllSongs(); } - String itemType = "Audio"; + String? itemType = TabContentType.songs.itemType.idString; String? alternativeQuery; bool searchForPlaylists = false; if (searchQuery.extras?["android.intent.extra.album"] != null && searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] != null) { // if all metadata is provided, search for song - itemType = "Audio"; + itemType = TabContentType.songs.itemType.idString; alternativeQuery = searchQuery.extras?["android.intent.extra.title"]; } else if (searchQuery.extras?["android.intent.extra.album"] != null && searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] == null) { // if only album is provided, search for album - itemType = "MusicAlbum"; + itemType = TabContentType.albums.itemType.idString; alternativeQuery = searchQuery.extras?["android.intent.extra.album"]; } else if (searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] == null) { // if only artist is provided, search for artist - itemType = "MusicArtist"; + itemType = TabContentType.artists.itemType.idString; alternativeQuery = searchQuery.extras?["android.intent.extra.artist"]; } else { // if no metadata is provided, search for song *and* playlists, preferring playlists @@ -246,14 +254,14 @@ class AndroidAutoHelper { try { List? searchResult = await jellyfinApiHelper.getItems( parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: "Playlist", + includeItemTypes: TabContentType.playlists.itemType.idString, searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), startIndex: 0, limit: 1, ); final playlist = searchResult![0]; - final items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: "Audio", sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + final items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); _androidAutoHelperLogger.info("Playing playlist: ${playlist.name} (${items?.length} songs)"); await queueService.startPlayback(items: items ?? [], source: QueueItemSource( @@ -301,9 +309,9 @@ class AndroidAutoHelper { } final selectedResult = searchResult.firstWhere((element) { - if (itemType == "Audio" && searchQuery.extras?["android.intent.extra.artist"] != null) { + if (itemType == TabContentType.songs.itemType.idString && searchQuery.extras?["android.intent.extra.artist"] != null) { return element.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (searchQuery.extras?["android.intent.extra.artist"]?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false; - } else if (itemType == "MusicAlbum" && searchQuery.extras?["android.intent.extra.artist"] != null) { + } else if (itemType == TabContentType.songs.itemType.idString && searchQuery.extras?["android.intent.extra.artist"] != null) { return element.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (searchQuery.extras?["android.intent.extra.artist"]?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false; } else { return false; @@ -313,9 +321,9 @@ class AndroidAutoHelper { _androidAutoHelperLogger.info("Playing from search: ${selectedResult.name}"); - if (itemType == "MusicAlbum") { + if (itemType == TabContentType.albums.itemType.idString) { final album = await _jellyfinApiHelper.getItemById(selectedResult.id); - final items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: "Audio", sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + final items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); _androidAutoHelperLogger.info("Playing album: ${album.name} (${items?.length} songs)"); await queueService.startPlayback(items: items ?? [], source: QueueItemSource( @@ -328,7 +336,7 @@ class AndroidAutoHelper { ), order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? ); - } else if (itemType == "MusicArtist") { + } else if (itemType == TabContentType.artists.itemType.idString) { await audioServiceHelper.startInstantMixForArtists([selectedResult]).then((value) => 1); } else { await audioServiceHelper.startInstantMixForItem(selectedResult).then((value) => 1); @@ -374,7 +382,7 @@ class AndroidAutoHelper { _androidAutoHelperLogger.warning("Tried to play from media id with invalid parent type '${itemId.parentType.name}' or null id"); return; } - // get all songs of current parrent + // get all songs of current parent final parentItem = await getParentFromId(itemId.itemId!); // start instant mix for artists diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 0d518b36d..d76d0274e 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -374,6 +374,41 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } } + List _getRootMenu() { + return !FinampSettingsHelper.finampSettings.isOffline ? [ + MediaItem( + id: MediaItemId(contentType: TabContentType.albums, parentType: MediaItemParentType.rootCollection).toString(), + title: _appLocalizations?.albums ?? TabContentType.albums.toString(), + playable: false + ), + MediaItem( + id: MediaItemId(contentType: TabContentType.artists, parentType: MediaItemParentType.rootCollection).toString(), + title: _appLocalizations?.artists ?? TabContentType.artists.toString(), + playable: false + ), + MediaItem( + id: MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection).toString(), + title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), + playable: false + ), + MediaItem( + id: MediaItemId(contentType: TabContentType.genres, parentType: MediaItemParentType.rootCollection).toString(), + title: _appLocalizations?.genres ?? TabContentType.genres.toString(), + playable: false + )] : [ // display only albums and playlists if in offline mode + MediaItem( + id: MediaItemId(contentType: TabContentType.albums, parentType: MediaItemParentType.rootCollection).toString(), + title: _appLocalizations?.albums ?? TabContentType.albums.toString(), + playable: false + ), + MediaItem( + id: MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection).toString(), + title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), + playable: false + ), + ]; + } + // menus @override Future> getChildren(String parentMediaId, [Map? options]) async { @@ -386,38 +421,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { _localizationsInitialized = true; } - return !FinampSettingsHelper.finampSettings.isOffline ? [ - MediaItem( - id: MediaItemId(contentType: TabContentType.albums, parentType: MediaItemParentType.rootCollection).toString(), - title: _appLocalizations?.albums ?? TabContentType.albums.toString(), - playable: false - ), - MediaItem( - id: MediaItemId(contentType: TabContentType.artists, parentType: MediaItemParentType.rootCollection).toString(), - title: _appLocalizations?.artists ?? TabContentType.artists.toString(), - playable: false - ), - MediaItem( - id: MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection).toString(), - title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), - playable: false - ), - MediaItem( - id: MediaItemId(contentType: TabContentType.genres, parentType: MediaItemParentType.rootCollection).toString(), - title: _appLocalizations?.genres ?? TabContentType.genres.toString(), - playable: false - )] : [ // display only albums and playlists if in offline mode - MediaItem( - id: MediaItemId(contentType: TabContentType.albums, parentType: MediaItemParentType.rootCollection).toString(), - title: _appLocalizations?.albums ?? TabContentType.albums.toString(), - playable: false - ), - MediaItem( - id: MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection).toString(), - title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), - playable: false - ), - ]; + return _getRootMenu(); } // else if (parentMediaId == AudioService.recentRootId) { // return await _androidAutoHelper.getRecentItems(); From 09df0c1452e3de8445147cc16463a951817ded91 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 10 Mar 2024 13:08:01 +0100 Subject: [PATCH 29/42] only resolve songs from downloads in online mode, add artist playback when offline --- lib/services/android_auto_helper.dart | 65 +++++++++++++++++++++------ 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index d26bf357c..fa4c60eea 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:audio_service/audio_service.dart'; +import 'package:collection/collection.dart'; import 'package:finamp/services/downloads_service.dart'; import 'package:get_it/get_it.dart'; @@ -52,7 +53,7 @@ class AndroidAutoHelper { Future> getBaseItems(MediaItemId itemId) async { // offline mode only supports albums and playlists for now (no offline instant mix for others yet) - if (FinampSettingsHelper.finampSettings.isOffline && (itemId.contentType == TabContentType.artists || itemId.contentType == TabContentType.genres)) { + if (FinampSettingsHelper.finampSettings.isOffline && (itemId.contentType == TabContentType.genres)) { return []; } @@ -75,18 +76,43 @@ class AndroidAutoHelper { return _sortItems(baseItems, sortBy, sortOrder); } - // try to use downloaded parent first - if (itemId.parentType == MediaItemParentType.collection) { - var downloadedParent = await _downloadService.getCollectionInfo(id: itemId.itemId); - if (downloadedParent != null && downloadedParent.baseItem != null) { - final downloadedItems = await _downloadService.getCollectionSongs(downloadedParent.baseItem!); - if (downloadedItems.length >= limit) { - downloadedItems.removeRange(limit, downloadedItems.length - 1); + // use downloaded parent only in offline mode + // otherwise we only play downloaded songs from albums/collections, not all of them + // downloaded songs will be played from device when resolving them to media items + if (FinampSettingsHelper.finampSettings.isOffline && itemId.parentType == MediaItemParentType.collection) { + + if (itemId.contentType == TabContentType.genres) { + return []; + } else if (itemId.contentType == TabContentType.artists) { + + final artistBaseItem = await getParentFromId(itemId.itemId!); + + final List artistAlbums = (await _downloadService.getAllCollections( + baseTypeFilter: BaseItemDtoType.album, + relatedTo: artistBaseItem)).toList() + .map((e) => e.baseItem).whereNotNull().toList(); + artistAlbums.sort((a, b) => (a.premiereDate ?? "") + .compareTo(b.premiereDate ?? "")); + + final List sortedSongs = []; + for (var album in artistAlbums) { + sortedSongs.addAll(await _downloadService + .getCollectionSongs(album, playable: true)); } + return sortedSongs; + } else { + var downloadedParent = await _downloadService.getCollectionInfo(id: itemId.itemId); + if (downloadedParent != null && downloadedParent.baseItem != null) { + final downloadedItems = await _downloadService.getCollectionSongs(downloadedParent.baseItem!); + if (downloadedItems.length >= limit) { + downloadedItems.removeRange(limit, downloadedItems.length - 1); + } - // only sort items if we are not playing them - return _isPlayable(itemId.contentType) ? downloadedItems : _sortItems(downloadedItems, sortBy, sortOrder); + // only sort items if we are not playing them + return _isPlayable(itemId.contentType) ? downloadedItems : _sortItems(downloadedItems, sortBy, sortOrder); + } } + } // fetch the online version if we can't get offline version @@ -367,6 +393,8 @@ class AndroidAutoHelper { Future playFromMediaId(MediaItemId itemId) async { final audioServiceHelper = GetIt.instance(); + // queue service should be initialized by time we get here + final queueService = GetIt.instance(); // shouldn't happen, but just in case if (!_isPlayable(itemId.contentType)) { @@ -390,7 +418,20 @@ class AndroidAutoHelper { // we don't show artists in offline mode, and parent item can't be null for mix // this shouldn't happen, but just in case if (FinampSettingsHelper.finampSettings.isOffline || parentItem == null) { - return; + final parentBaseItems = await getBaseItems(itemId); + + return await queueService.startPlayback( + items: parentBaseItems, + source: QueueItemSource( + type: QueueItemSourceType.artist, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem?.name), + id: parentItem?.id ?? itemId.parentId!, + item: parentItem, + ), + order: FinampPlaybackOrder.linear, + ); } return await audioServiceHelper.startInstantMixForArtists([parentItem]); @@ -398,8 +439,6 @@ class AndroidAutoHelper { final parentBaseItems = await getBaseItems(itemId); - // queue service should be initialized by time we get here - final queueService = GetIt.instance(); await queueService.startPlayback(items: parentBaseItems, source: QueueItemSource( type: itemId.contentType == TabContentType.playlists ? QueueItemSourceType.playlist From 40e5bb50d36e0196489b1b7617fe41b41a1b06e1 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 10 Mar 2024 15:08:08 +0100 Subject: [PATCH 30/42] also use grid layout in Android Auto if enabled --- lib/main.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index c0b47d467..2614d89b7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -247,11 +247,16 @@ Future _setupPlaybackServices() async { config: AudioServiceConfig( androidStopForegroundOnPause: FinampSettingsHelper.finampSettings.androidStopForegroundOnPause, - androidNotificationChannelName: "Playback", + androidNotificationChannelName: "Finamp", androidNotificationIcon: "mipmap/white", androidNotificationChannelId: "com.unicornsonlsd.finamp.audio", + // notificationColor: TODO use the theme color for older versions of Android, + preloadArtwork: true, androidBrowsableRootExtras: { "android.media.browse.SEARCH_SUPPORTED" : true, // support showing search button on Android Auto as well as alternative search results on the player screen after voice search + // see https://developer.android.com/reference/androidx/media/utils/MediaConstants#DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM() + "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT": FinampSettingsHelper.finampSettings.contentViewType == ContentViewType.list ? 1 : 2, + "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT": FinampSettingsHelper.finampSettings.contentViewType == ContentViewType.list ? 1 : 2, } ), ); From c4baf8562b9d26a31b3bdff7b5650a2c97378e95 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 10 Mar 2024 21:09:50 +0100 Subject: [PATCH 31/42] improve offline mode for Android Auto - removes old limitations --- lib/services/album_image_provider.dart | 2 +- lib/services/android_auto_helper.dart | 111 +++++++++++------- .../music_player_background_task.dart | 23 +--- lib/services/queue_service.dart | 14 ++- 4 files changed, 81 insertions(+), 69 deletions(-) diff --git a/lib/services/album_image_provider.dart b/lib/services/album_image_provider.dart index 60b79cfa0..aede84537 100644 --- a/lib/services/album_image_provider.dart +++ b/lib/services/album_image_provider.dart @@ -50,7 +50,7 @@ final AutoDisposeFutureProviderFamily try { downloadedImage = await isardownloader.getImageDownload(item: request.item); } catch (e) { - albumImageProviderLogger.warning("Couldn't get the offline image for track '${request.item.name}' because it's missing a blurhash"); + albumImageProviderLogger.warning("Couldn't get the offline image for track '${request.item.name}' because it's not downloaded or missing a blurhash"); } if (downloadedImage?.file == null) { diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index fa4c60eea..1dfd6ec45 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -52,14 +52,10 @@ class AndroidAutoHelper { } Future> getBaseItems(MediaItemId itemId) async { - // offline mode only supports albums and playlists for now (no offline instant mix for others yet) - if (FinampSettingsHelper.finampSettings.isOffline && (itemId.contentType == TabContentType.genres)) { - return []; - } - // limit amount so it doesn't crash on large libraries - // TODO: add pagination - const limit = 100; + // limit amount so it doesn't crash / take forever on large libraries + const onlineModeLimit = 250; + const offlineModeLimit = 1000; final sortBy = FinampSettingsHelper.finampSettings.getTabSortBy(itemId.contentType); final sortOrder = FinampSettingsHelper.finampSettings.getSortOrder(itemId.contentType); @@ -68,7 +64,7 @@ class AndroidAutoHelper { if (FinampSettingsHelper.finampSettings.isOffline && itemId.parentType == MediaItemParentType.rootCollection) { List baseItems = []; for (final downloadedParent in await _downloadService.getAllCollections()) { - if (baseItems.length >= limit) break; + if (baseItems.length >= offlineModeLimit) break; if (downloadedParent.baseItem != null && downloadedParent.baseItemType == itemId.contentType.itemType) { baseItems.add(downloadedParent.baseItem!); } @@ -82,7 +78,15 @@ class AndroidAutoHelper { if (FinampSettingsHelper.finampSettings.isOffline && itemId.parentType == MediaItemParentType.collection) { if (itemId.contentType == TabContentType.genres) { - return []; + final genreBaseItem = await getParentFromId(itemId.itemId!); + + final List genreAlbums = (await _downloadService.getAllCollections( + baseTypeFilter: BaseItemDtoType.album, + relatedTo: genreBaseItem)).toList() + .map((e) => e.baseItem).whereNotNull().toList(); + genreAlbums.sort((a, b) => (a.premiereDate ?? "") + .compareTo(b.premiereDate ?? "")); + return genreAlbums; } else if (itemId.contentType == TabContentType.artists) { final artistBaseItem = await getParentFromId(itemId.itemId!); @@ -94,18 +98,18 @@ class AndroidAutoHelper { artistAlbums.sort((a, b) => (a.premiereDate ?? "") .compareTo(b.premiereDate ?? "")); - final List sortedSongs = []; + final List allSongs = []; for (var album in artistAlbums) { - sortedSongs.addAll(await _downloadService + allSongs.addAll(await _downloadService .getCollectionSongs(album, playable: true)); } - return sortedSongs; + return allSongs; } else { var downloadedParent = await _downloadService.getCollectionInfo(id: itemId.itemId); if (downloadedParent != null && downloadedParent.baseItem != null) { final downloadedItems = await _downloadService.getCollectionSongs(downloadedParent.baseItem!); - if (downloadedItems.length >= limit) { - downloadedItems.removeRange(limit, downloadedItems.length - 1); + if (downloadedItems.length >= offlineModeLimit) { + downloadedItems.removeRange(offlineModeLimit, downloadedItems.length - 1); } // only sort items if we are not playing them @@ -132,7 +136,7 @@ class AndroidAutoHelper { ? BaseItemDto(id: itemId.itemId!, type: itemId.contentType.itemType.idString) : _finampUserHelper.currentUser?.currentView; - final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(itemId.contentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, limit: limit); + final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(itemId.contentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, limit: onlineModeLimit); return items ?? []; } @@ -277,32 +281,34 @@ class AndroidAutoHelper { _androidAutoHelperLogger.info("Searching for: $itemType that matches query '${alternativeQuery ?? searchQuery.query}'${searchForPlaylists ? ", including (and preferring) playlists" : ""}"); - try { - List? searchResult = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: TabContentType.playlists.itemType.idString, - searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), - startIndex: 0, - limit: 1, - ); + if (searchForPlaylists) { + try { + List? searchResult = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: TabContentType.playlists.itemType.idString, + searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), + startIndex: 0, + limit: 1, + ); - final playlist = searchResult![0]; - final items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); - _androidAutoHelperLogger.info("Playing playlist: ${playlist.name} (${items?.length} songs)"); - - await queueService.startPlayback(items: items ?? [], source: QueueItemSource( - type: QueueItemSourceType.playlist, - name: QueueItemSourceName( - type: QueueItemSourceNameType.preTranslated, - pretranslatedName: playlist.name), - id: playlist.id, - item: playlist, - ), - order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? - ); + final playlist = searchResult![0]; + final items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + _androidAutoHelperLogger.info("Playing playlist: ${playlist.name} (${items?.length} songs)"); - } catch (e) { - _androidAutoHelperLogger.warning("Couldn't search for playlists:", e); + await queueService.startPlayback(items: items ?? [], source: QueueItemSource( + type: QueueItemSourceType.playlist, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: playlist.name), + id: playlist.id, + item: playlist, + ), + order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? + ); + + } catch (e) { + _androidAutoHelperLogger.warning("Couldn't search for playlists: $e"); + } } try { @@ -363,12 +369,29 @@ class AndroidAutoHelper { order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? ); } else if (itemType == TabContentType.artists.itemType.idString) { - await audioServiceHelper.startInstantMixForArtists([selectedResult]).then((value) => 1); + if (FinampSettingsHelper.finampSettings.isOffline) { + final parentBaseItems = await getBaseItems(MediaItemId(contentType: TabContentType.songs, parentType: MediaItemParentType.collection, parentId: selectedResult.id)); + + await queueService.startPlayback( + items: parentBaseItems, + source: QueueItemSource( + type: QueueItemSourceType.artist, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: selectedResult.name), + id: selectedResult.id, + item: selectedResult, + ), + order: FinampPlaybackOrder.linear, + ); + } else { + await audioServiceHelper.startInstantMixForArtists([selectedResult]).then((value) => 1); + } } else { await audioServiceHelper.startInstantMixForItem(selectedResult).then((value) => 1); } } catch (err) { - _androidAutoHelperLogger.severe("Error while playing from search query:", err); + _androidAutoHelperLogger.severe("Error while playing from search query: $err"); } } @@ -378,7 +401,7 @@ class AndroidAutoHelper { try { await audioServiceHelper.shuffleAll(FinampSettingsHelper.finampSettings.onlyShowFavourite); } catch (err) { - _androidAutoHelperLogger.severe("Error while shuffling all songs:", err); + _androidAutoHelperLogger.severe("Error while shuffling all songs: $err"); } } @@ -415,8 +438,6 @@ class AndroidAutoHelper { // start instant mix for artists if (itemId.contentType == TabContentType.artists) { - // we don't show artists in offline mode, and parent item can't be null for mix - // this shouldn't happen, but just in case if (FinampSettingsHelper.finampSettings.isOffline || parentItem == null) { final parentBaseItems = await getBaseItems(itemId); @@ -540,7 +561,7 @@ class AndroidAutoHelper { try { downloadedImage = await _downloadService.getImageDownload(item: item); } catch (e) { - _androidAutoHelperLogger.warning("Couldn't get the offline image for track '${item.name}' because it's missing a blurhash"); + _androidAutoHelperLogger.warning("Couldn't get the offline image for track '${item.name}' because it's not downloaded or missing a blurhash"); } Uri? artUri; diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index d76d0274e..b10320bf5 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -375,38 +375,27 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } List _getRootMenu() { - return !FinampSettingsHelper.finampSettings.isOffline ? [ + return [ MediaItem( id: MediaItemId(contentType: TabContentType.albums, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.albums ?? TabContentType.albums.toString(), - playable: false + playable: false, ), MediaItem( id: MediaItemId(contentType: TabContentType.artists, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.artists ?? TabContentType.artists.toString(), - playable: false + playable: false, ), MediaItem( id: MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), - playable: false + playable: false, ), MediaItem( id: MediaItemId(contentType: TabContentType.genres, parentType: MediaItemParentType.rootCollection).toString(), title: _appLocalizations?.genres ?? TabContentType.genres.toString(), - playable: false - )] : [ // display only albums and playlists if in offline mode - MediaItem( - id: MediaItemId(contentType: TabContentType.albums, parentType: MediaItemParentType.rootCollection).toString(), - title: _appLocalizations?.albums ?? TabContentType.albums.toString(), - playable: false - ), - MediaItem( - id: MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection).toString(), - title: _appLocalizations?.playlists ?? TabContentType.playlists.toString(), - playable: false - ), - ]; + playable: false, + )]; } // menus diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 8d8e7c85a..82a1dae57 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -71,7 +71,7 @@ class QueueService { // Flags for saving and loading saved queues int _saveUpdateCycleCount = 0; - bool _saveUpdateImemdiate = false; + bool _saveUpdateImmediate = false; SavedQueueState _savedQueueState = SavedQueueState.preInit; FinampStorableQueueInfo? _failedSavedQueue = null; static const int _maxSavedQueues = 60; @@ -102,7 +102,7 @@ class QueueService { "Play queue index changed, new index: $_queueAudioSourceIndex"); _queueFromConcatenatingAudioSource(); } else { - _saveUpdateImemdiate = true; + _saveUpdateImmediate = true; } }); @@ -110,13 +110,13 @@ class QueueService { // Update once per minute in background, and up to once every ten seconds if // pausing/seeking is occuring // We also update on every track switch. - if (_saveUpdateCycleCount >= 5 || _saveUpdateImemdiate) { + if (_saveUpdateCycleCount >= 5 || _saveUpdateImmediate) { if (_savedQueueState == SavedQueueState.pendingSave && !_audioHandler.paused) { _savedQueueState = SavedQueueState.saving; } if (_savedQueueState == SavedQueueState.saving) { - _saveUpdateImemdiate = false; + _saveUpdateImmediate = false; _saveUpdateCycleCount = 0; FinampStorableQueueInfo info = FinampStorableQueueInfo.fromQueueInfo( getQueue(), _audioHandler.playbackPosition.inMilliseconds); @@ -229,6 +229,8 @@ class QueueService { .followedBy(_queue) .map((e) => e.item) .toList()); + // _audioHandler.queueTitle.add(_order.originalSource.name.toString()); + _audioHandler.queueTitle.add("Finamp"); if (_savedQueueState == SavedQueueState.saving) { FinampStorableQueueInfo info = @@ -237,7 +239,7 @@ class QueueService { _queuesBox.put("latest", info); _queueServiceLogger.finest("Saved new rebuilt queue $info"); } - _saveUpdateImemdiate = false; + _saveUpdateImmediate = false; _saveUpdateCycleCount = 0; } @@ -837,7 +839,7 @@ class QueueService { try { downloadedImage = await _isarDownloader.getImageDownload(item: item); } catch (e) { - _queueServiceLogger.warning("Couldn't get the offline image for track '${item.name}' because it's missing a blurhash"); + _queueServiceLogger.warning("Couldn't get the offline image for track '${item.name}' because it's not downloaded or missing a blurhash"); } Uri? artUri; From d1aefd192382315b50d83e8c36ac56832daa25a2 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 24 Mar 2024 00:01:05 +0100 Subject: [PATCH 32/42] merge branch 'redesign' --- .../AlbumScreen/song_list_tile.dart | 29 +- lib/components/AlbumScreen/song_menu.dart | 36 +-- .../MusicScreen/music_screen_tab_view.dart | 5 +- .../PlayerScreen/player_buttons.dart | 12 +- .../PlayerScreen/player_buttons_more.dart | 37 ++- .../player_buttons_repeating.dart | 18 +- .../PlayerScreen/player_buttons_shuffle.dart | 7 +- .../PlayerScreen/progress_slider.dart | 8 +- lib/components/PlayerScreen/queue_list.dart | 287 +++++++++++------- .../PlayerScreen/queue_list_item.dart | 23 +- .../PlayerScreen/song_name_content.dart | 3 +- lib/components/now_playing_bar.dart | 8 +- lib/screens/music_screen.dart | 3 + .../music_player_background_task.dart | 57 ++-- lib/services/playback_history_service.dart | 9 +- 15 files changed, 341 insertions(+), 201 deletions(-) diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index c1971955b..f9b22a158 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -278,27 +278,28 @@ class _SongListTileState extends ConsumerState direction: FinampSettingsHelper.finampSettings.disableGesture ? DismissDirection.none : DismissDirection.horizontal, + dismissThresholds: const {DismissDirection.startToEnd: 0.5, DismissDirection.endToStart: 0.5}, background: Container( color: Theme.of(context).colorScheme.secondaryContainer, alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - AspectRatio( - aspectRatio: 1, - child: FittedBox( - fit: BoxFit.fitHeight, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Icon( - TablerIcons.playlist, - color: Theme.of(context) - .colorScheme - .onSecondaryContainer, - ), - ), - ), + Icon( + TablerIcons.playlist, + color: Theme.of(context) + .colorScheme + .onSecondaryContainer, + size: 40, + ), + Icon( + TablerIcons.playlist, + color: Theme.of(context) + .colorScheme + .onSecondaryContainer, + size: 40, ) ], ), diff --git a/lib/components/AlbumScreen/song_menu.dart b/lib/components/AlbumScreen/song_menu.dart index b395f3caa..6bc5bed6d 100644 --- a/lib/components/AlbumScreen/song_menu.dart +++ b/lib/components/AlbumScreen/song_menu.dart @@ -372,6 +372,24 @@ class _SongMenuState extends State { padding: const EdgeInsets.only(left: 8.0), sliver: SliverList( delegate: SliverChildListDelegate([ + Visibility( + visible: !widget.isOffline, + child: ListTile( + leading: Icon( + Icons.playlist_add, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)! + .addToPlaylistTitle), + enabled: !widget.isOffline, + onTap: () { + Navigator.pop(context); // close menu + Navigator.of(context).pushNamed( + AddToPlaylistScreen.routeName, + arguments: widget.item.id); + }, + ), + ), ListTile( enabled: !widget.isOffline, leading: widget.item.userData!.isFavorite @@ -504,24 +522,6 @@ class _SongMenuState extends State { }, ), ), - Visibility( - visible: !widget.isOffline, - child: ListTile( - leading: Icon( - Icons.playlist_add, - color: iconColor, - ), - title: Text(AppLocalizations.of(context)! - .addToPlaylistTitle), - enabled: !widget.isOffline, - onTap: () { - Navigator.pop(context); - Navigator.of(context).pushNamed( - AddToPlaylistScreen.routeName, - arguments: widget.item.id); - }, - ), - ), Visibility( visible: !widget.isOffline, child: ListTile( diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart index 29fa01198..8e417bb77 100644 --- a/lib/components/MusicScreen/music_screen_tab_view.dart +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -74,8 +74,9 @@ class _MusicScreenTabViewState extends State settings.tabSortOrder[widget.tabContentType]?.toString() ?? SortOrder.ascending.toString(); final newItems = await _jellyfinApiHelper.getItems( - parentItem: widget.view ?? - _finampUserHelper.currentUser?.currentView, + // starting with Jellyfin 10.9, only automatically created playlists will have a specific library as parent. user-created playlists will not be returned anymore + // this condition fixes this by not providing a parentId when fetching playlists + parentItem: widget.tabContentType.itemType == BaseItemDtoType.playlist ? null : (widget.view ?? _finampUserHelper.currentUser?.currentView), includeItemTypes: widget.tabContentType.itemType.idString, // If we're on the songs tab, sort by "Album,SortName". This is what the diff --git a/lib/components/PlayerScreen/player_buttons.dart b/lib/components/PlayerScreen/player_buttons.dart index 79fbe4afb..f7a6281e1 100644 --- a/lib/components/PlayerScreen/player_buttons.dart +++ b/lib/components/PlayerScreen/player_buttons.dart @@ -3,6 +3,7 @@ import 'package:finamp/services/queue_service.dart'; import 'package:finamp/components/PlayerScreen/player_buttons_shuffle.dart'; import 'package:flutter/material.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; import 'package:get_it/get_it.dart'; import '../../services/media_state_stream.dart'; @@ -31,7 +32,10 @@ class PlayerButtons extends StatelessWidget { IconButton( icon: const Icon(TablerIcons.player_skip_back), onPressed: playbackState != null - ? () async => await audioHandler.skipToPrevious() + ? () async { + Vibrate.feedback(FeedbackType.light); + await audioHandler.skipToPrevious(); + } : null, ), _RoundedIconButton( @@ -40,6 +44,7 @@ class PlayerButtons extends StatelessWidget { borderRadius: BorderRadius.circular(16), onTap: playbackState != null ? () async { + Vibrate.feedback(FeedbackType.light); if (playbackState.playing) { await audioHandler.pause(); } else { @@ -56,7 +61,10 @@ class PlayerButtons extends StatelessWidget { IconButton( icon: const Icon(TablerIcons.player_skip_forward), onPressed: playbackState != null - ? () async => audioHandler.skipToNext() + ? () async { + Vibrate.feedback(FeedbackType.light); + await audioHandler.skipToNext(); + } : null, ), PlayerButtonsShuffle() diff --git a/lib/components/PlayerScreen/player_buttons_more.dart b/lib/components/PlayerScreen/player_buttons_more.dart index d3fa41bbb..c7a2f2d39 100644 --- a/lib/components/PlayerScreen/player_buttons_more.dart +++ b/lib/components/PlayerScreen/player_buttons_more.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; import 'package:get_it/get_it.dart'; enum PlayerButtonsMoreItems { shuffle, repeat, addToPlaylist, sleepTimer } @@ -32,21 +33,29 @@ class PlayerButtonsMore extends ConsumerWidget { : colorScheme.primary, size: 24, ), - child: IconButton( - icon: const Icon( - TablerIcons.menu_2, - ), - visualDensity: VisualDensity.compact, - onPressed: () async { - if (item == null) return; - await showModalSongMenu( - context: context, - item: item!, - playerScreenTheme: colorScheme, - showPlaybackControls: true, // show controls on player screen - isInPlaylist: false, - ); + child: GestureDetector( + onLongPress: () { + Vibrate.feedback(FeedbackType.medium); + Navigator.of(context).pushNamed( + AddToPlaylistScreen.routeName, + arguments: item!.id); }, + child: IconButton( + icon: const Icon( + TablerIcons.menu_2, + ), + visualDensity: VisualDensity.compact, + onPressed: () async { + if (item == null) return; + await showModalSongMenu( + context: context, + item: item!, + playerScreenTheme: colorScheme, + showPlaybackControls: true, // show controls on player screen + isInPlaylist: false, + ); + }, + ), ), ); } diff --git a/lib/components/PlayerScreen/player_buttons_repeating.dart b/lib/components/PlayerScreen/player_buttons_repeating.dart index 85d389da0..7a43ad393 100644 --- a/lib/components/PlayerScreen/player_buttons_repeating.dart +++ b/lib/components/PlayerScreen/player_buttons_repeating.dart @@ -6,6 +6,7 @@ import 'package:finamp/services/queue_service.dart'; import 'package:finamp/services/player_screen_theme_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; import 'package:get_it/get_it.dart'; class PlayerButtonsRepeating extends StatelessWidget { @@ -23,21 +24,8 @@ class PlayerButtonsRepeating extends StatelessWidget { builder: (BuildContext context, AsyncSnapshot snapshot) { return IconButton( onPressed: () async { - // Cycles from none -> all -> one - switch (queueService.loopMode) { - case FinampLoopMode.none: - queueService.loopMode = FinampLoopMode.all; - break; - case FinampLoopMode.all: - queueService.loopMode = FinampLoopMode.one; - break; - case FinampLoopMode.one: - queueService.loopMode = FinampLoopMode.none; - break; - default: - queueService.loopMode = FinampLoopMode.none; - break; - } + Vibrate.feedback(FeedbackType.light); + queueService.toggleLoopMode(); }, icon: _getRepeatingIcon( queueService.loopMode, diff --git a/lib/components/PlayerScreen/player_buttons_shuffle.dart b/lib/components/PlayerScreen/player_buttons_shuffle.dart index afddb0f71..6d91097c4 100644 --- a/lib/components/PlayerScreen/player_buttons_shuffle.dart +++ b/lib/components/PlayerScreen/player_buttons_shuffle.dart @@ -5,6 +5,7 @@ import 'package:finamp/services/music_player_background_task.dart'; import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; import 'package:get_it/get_it.dart'; class PlayerButtonsShuffle extends StatelessWidget { @@ -20,10 +21,8 @@ class PlayerButtonsShuffle extends StatelessWidget { builder: (BuildContext context, AsyncSnapshot snapshot) { return IconButton( onPressed: () async { - _queueService.playbackOrder = - _queueService.playbackOrder == FinampPlaybackOrder.shuffled - ? FinampPlaybackOrder.linear - : FinampPlaybackOrder.shuffled; + Vibrate.feedback(FeedbackType.light); + _queueService.togglePlaybackOrder(); }, icon: Icon( (_queueService.playbackOrder == FinampPlaybackOrder.shuffled diff --git a/lib/components/PlayerScreen/progress_slider.dart b/lib/components/PlayerScreen/progress_slider.dart index 44a6c8c0d..e2a010e91 100644 --- a/lib/components/PlayerScreen/progress_slider.dart +++ b/lib/components/PlayerScreen/progress_slider.dart @@ -290,9 +290,11 @@ class __PlaybackProgressSliderState // Clear drag value so that the slider uses the play // duration again. - setState(() { - _dragValue = null; - }); + if (mounted) { + setState(() { + _dragValue = null; + }); + } widget.onDrag(null); } : (_) {}, diff --git a/lib/components/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart index 0093347a2..639ee8e39 100644 --- a/lib/components/PlayerScreen/queue_list.dart +++ b/lib/components/PlayerScreen/queue_list.dart @@ -6,11 +6,11 @@ import 'package:finamp/components/favourite_button.dart'; import 'package:finamp/components/global_snackbar.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/screens/blurred_player_screen_background.dart'; -import 'package:finamp/services/audio_service_helper.dart'; import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:finamp/services/jellyfin_api_helper.dart'; import 'package:finamp/services/player_screen_theme_provider.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; @@ -46,6 +46,7 @@ class QueueList extends StatefulWidget { required this.currentTrackKey, required this.nextUpHeaderKey, required this.queueHeaderKey, + required this.jumpToCurrentKey, }) : super(key: key); final ScrollController scrollController; @@ -53,6 +54,7 @@ class QueueList extends StatefulWidget { final Key currentTrackKey; final GlobalKey nextUpHeaderKey; final GlobalKey queueHeaderKey; + final GlobalKey jumpToCurrentKey; @override State createState() => _QueueListState(); @@ -88,6 +90,8 @@ class _QueueListState extends State { QueueItemSource? _source; + double _currentTrackScroll = 0; + late List _contents; BehaviorSubject isRecentTracksExpanded = BehaviorSubject.seeded(false); @@ -139,6 +143,13 @@ class _QueueListState extends State { children: const [], ), ]; + + widget.scrollController.addListener(() { + final screenSize = MediaQuery.of(context).size; + double offset = widget.scrollController.offset - _currentTrackScroll; + bool showJump = offset > screenSize.height*0.5 || offset < - screenSize.height; + widget.jumpToCurrentKey.currentState?.showJumpToTop = showJump; + }); } void scrollToCurrentTrack() { @@ -178,6 +189,10 @@ class _QueueListState extends State { final previousTracks = _queueService.getQueue().previousTracks; // a random delay isn't a great solution, but I'm not sure how to do this properly Future.delayed(Duration(milliseconds: expanded ? 5 : 50), () { + _currentTrackScroll = expanded + ? 0 + : widget.scrollController.position.maxScrollExtent - + oldBottomOffset; widget.scrollController.jumpTo( widget.scrollController.position.maxScrollExtent - oldBottomOffset - @@ -218,7 +233,7 @@ class _QueueListState extends State { NextUpTracksList(previousTracksHeaderKey: widget.previousTracksHeaderKey), SliverPadding( key: widget.queueHeaderKey, - padding: const EdgeInsets.only(top: 20.0, bottom: 0.0), + padding: const EdgeInsets.only(top: 16.0, bottom: 0.0), sliver: SliverPersistentHeader( pinned: true, delegate: QueueSectionHeader( @@ -281,6 +296,7 @@ Future showQueueBottomSheet(BuildContext context) { Key currentTrackKey = UniqueKey(); GlobalKey nextUpHeaderKey = GlobalKey(); GlobalKey queueHeaderKey = GlobalKey(); + GlobalKey jumpToCurrentKey = GlobalKey(); Vibrate.feedback(FeedbackType.impact); @@ -300,7 +316,6 @@ Future showQueueBottomSheet(BuildContext context) { builder: (BuildContext context, WidgetRef ref, Widget? child) { final imageTheme = ref.watch(playerScreenThemeProvider(Theme.of(context).brightness)); - return AnimatedTheme( duration: const Duration(milliseconds: 500), data: ThemeData( @@ -357,37 +372,18 @@ Future showQueueBottomSheet(BuildContext context) { currentTrackKey: currentTrackKey, nextUpHeaderKey: nextUpHeaderKey, queueHeaderKey: queueHeaderKey, + jumpToCurrentKey: jumpToCurrentKey, ), ), ], ), ], ), - //TODO fade this out if the current track is visible - floatingActionButton: FloatingActionButton( - onPressed: () { - Vibrate.feedback(FeedbackType.impact); - scrollToKey( - key: previousTracksHeaderKey, - duration: const Duration(milliseconds: 500)); - }, - backgroundColor: - IconTheme.of(context).color!.withOpacity(0.70), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16.0))), - child: Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: Icon( - TablerIcons.focus_2, - size: 28.0, - color: Colors.white.withOpacity(0.85), - ), - )), + floatingActionButton: JumpToCurrentButton( + key: jumpToCurrentKey, + previousTracksHeaderKey: previousTracksHeaderKey, + ), ); - // ) - // return QueueList( - // scrollController: scrollController, - // ); }, ), ); @@ -396,6 +392,50 @@ Future showQueueBottomSheet(BuildContext context) { ); } +class JumpToCurrentButton extends StatefulWidget { + const JumpToCurrentButton({super.key, required this.previousTracksHeaderKey}); + + final GlobalKey previousTracksHeaderKey; + + @override + State createState() => JumpToCurrentButtonState(); +} + +class JumpToCurrentButtonState extends State { + bool _showJumpToTop = false; + set showJumpToTop(bool show) { + if (show != _showJumpToTop) { + setState(() { + _showJumpToTop = show; + }); + } + } + + @override + Widget build(BuildContext context) { + return _showJumpToTop + ? FloatingActionButton( + onPressed: () { + Vibrate.feedback(FeedbackType.impact); + scrollToKey( + key: widget.previousTracksHeaderKey, + duration: const Duration(milliseconds: 500)); + }, + backgroundColor: IconTheme.of(context).color!.withOpacity(0.70), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16.0))), + child: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Icon( + TablerIcons.focus_2, + size: 28.0, + color: Colors.white.withOpacity(0.85), + ), + )) + : const SizedBox.shrink(); + } +} + class PreviousTracksList extends StatefulWidget { final GlobalKey previousTracksHeaderKey; @@ -592,73 +632,75 @@ class _QueueTracksListState extends State { @override Widget build(context) { - return StreamBuilder( - stream: _queueService.getQueueStream(), - builder: (context, snapshot) { - if (snapshot.hasData) { - _queue ??= snapshot.data!.queue; - _nextUp ??= snapshot.data!.nextUp; - - return SliverReorderableList( - autoScrollerVelocityScalar: 20.0, - onReorder: (oldIndex, newIndex) { - int draggingOffset = oldIndex + (_nextUp?.length ?? 0) + 1; - int newPositionOffset = newIndex + (_nextUp?.length ?? 0) + 1; - print("$draggingOffset -> $newPositionOffset"); - if (mounted) { - // update external queue to commit changes, but don't await it - _queueService.reorderByOffset( - draggingOffset, newPositionOffset); - Vibrate.feedback(FeedbackType.impact); - setState(() { - // temporarily update internal queue - FinampQueueItem tmp = _queue!.removeAt(oldIndex); - _queue!.insert( - newIndex < oldIndex ? newIndex : newIndex - 1, tmp); - }); - } - }, - onReorderStart: (p0) { - Vibrate.feedback(FeedbackType.selection); - }, - itemCount: _queue?.length ?? 0, - findChildIndexCallback: (Key key) { - key = key as GlobalObjectKey; - final ValueKey valueKey = key.value as ValueKey; - final index = - _queue!.indexWhere((item) => item.id == valueKey.value); - if (index == -1) return null; - return index; - }, - itemBuilder: (context, index) { - final item = _queue![index]; - final actualIndex = index; - final indexOffset = index + _nextUp!.length + 1; - - return QueueListItem( - key: ValueKey(item.id), - item: item, - listIndex: index, - actualIndex: actualIndex, - indexOffset: indexOffset, - subqueue: _queue!, - allowReorder: - _queueService.playbackOrder == FinampPlaybackOrder.linear, - onTap: () async { - Vibrate.feedback(FeedbackType.selection); - await _queueService.skipByOffset(indexOffset); - scrollToKey( - key: widget.previousTracksHeaderKey, - duration: const Duration(milliseconds: 500)); - }, - isCurrentTrack: false, - ); - }, - ); - } else { - return SliverList(delegate: SliverChildListDelegate([])); - } - }, + return QueueTracksMask( + child: StreamBuilder( + stream: _queueService.getQueueStream(), + builder: (context, snapshot) { + if (snapshot.hasData) { + _queue ??= snapshot.data!.queue; + _nextUp ??= snapshot.data!.nextUp; + + return SliverReorderableList( + autoScrollerVelocityScalar: 20.0, + onReorder: (oldIndex, newIndex) { + int draggingOffset = oldIndex + (_nextUp?.length ?? 0) + 1; + int newPositionOffset = newIndex + (_nextUp?.length ?? 0) + 1; + print("$draggingOffset -> $newPositionOffset"); + if (mounted) { + // update external queue to commit changes, but don't await it + _queueService.reorderByOffset( + draggingOffset, newPositionOffset); + Vibrate.feedback(FeedbackType.impact); + setState(() { + // temporarily update internal queue + FinampQueueItem tmp = _queue!.removeAt(oldIndex); + _queue!.insert( + newIndex < oldIndex ? newIndex : newIndex - 1, tmp); + }); + } + }, + onReorderStart: (p0) { + Vibrate.feedback(FeedbackType.selection); + }, + itemCount: _queue?.length ?? 0, + findChildIndexCallback: (Key key) { + key = key as GlobalObjectKey; + final ValueKey valueKey = key.value as ValueKey; + final index = + _queue!.indexWhere((item) => item.id == valueKey.value); + if (index == -1) return null; + return index; + }, + itemBuilder: (context, index) { + final item = _queue![index]; + final actualIndex = index; + final indexOffset = index + _nextUp!.length + 1; + + return QueueListItem( + key: ValueKey(item.id), + item: item, + listIndex: index, + actualIndex: actualIndex, + indexOffset: indexOffset, + subqueue: _queue!, + allowReorder: + _queueService.playbackOrder == FinampPlaybackOrder.linear, + onTap: () async { + Vibrate.feedback(FeedbackType.selection); + await _queueService.skipByOffset(indexOffset); + scrollToKey( + key: widget.previousTracksHeaderKey, + duration: const Duration(milliseconds: 500)); + }, + isCurrentTrack: false, + ); + }, + ); + } else { + return SliverList(delegate: SliverChildListDelegate([])); + } + }, + ), ); } } @@ -675,16 +717,12 @@ class CurrentTrack extends StatefulWidget { class _CurrentTrackState extends State { late QueueService _queueService; late MusicPlayerBackgroundTask _audioHandler; - late AudioServiceHelper _audioServiceHelper; - late JellyfinApiHelper _jellyfinApiHelper; @override void initState() { super.initState(); _queueService = GetIt.instance(); _audioHandler = GetIt.instance(); - _audioServiceHelper = GetIt.instance(); - _jellyfinApiHelper = GetIt.instance(); } @override @@ -718,7 +756,7 @@ class _CurrentTrackState extends State { leading: const Padding( padding: EdgeInsets.zero, ), - backgroundColor: const Color.fromRGBO(0, 0, 0, 0.0), + forceMaterialTransparency: true, flexibleSpace: Container( // width: 58, height: albumImageSize, @@ -1047,7 +1085,7 @@ class QueueSectionHeader extends SliverPersistentHeaderDelegate { required this.queueHeaderKey, required this.scrollController, this.controls = false, - this.height = 30.0, + this.height = 36.0, }); @override @@ -1063,14 +1101,17 @@ class QueueSectionHeader extends SliverPersistentHeaderDelegate { PlaybackBehaviorInfo? info = snapshot.data; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 14.0), + padding: const EdgeInsets.only(left: 16.0, right: 16.0), child: Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: GestureDetector( - child: title, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: title, + ), onTap: () { if (source != null) { navigateToSource(context, source!); @@ -1081,7 +1122,7 @@ class QueueSectionHeader extends SliverPersistentHeaderDelegate { Row( children: [ IconButton( - padding: const EdgeInsets.only(bottom: 2.0), + padding: EdgeInsets.zero, iconSize: 28.0, icon: info?.order == FinampPlaybackOrder.shuffled ? (const Icon( @@ -1106,7 +1147,7 @@ class QueueSectionHeader extends SliverPersistentHeaderDelegate { // scrollToKey(key: nextUpHeaderKey, duration: const Duration(milliseconds: 1000)); }), IconButton( - padding: const EdgeInsets.only(bottom: 2.0), + padding: EdgeInsets.zero, iconSize: 28.0, icon: info?.loop != FinampLoopMode.none ? (info?.loop == FinampLoopMode.one @@ -1301,3 +1342,41 @@ class PreviousTracksSectionHeader extends SliverPersistentHeaderDelegate { @override bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; } + +class QueueTracksMask extends SingleChildRenderObjectWidget { + const QueueTracksMask({ + super.key, + super.child, + }); + + @override + RenderQueueTracksMask createRenderObject(BuildContext context) { + return RenderQueueTracksMask(); + } +} + +class RenderQueueTracksMask extends RenderProxySliver { + @override + ShaderMaskLayer? get layer => super.layer as ShaderMaskLayer?; + + @override + bool get alwaysNeedsCompositing => child != null; + + @override + void paint(PaintingContext context, Offset offset) { + if (child != null) { + layer ??= ShaderMaskLayer( + shader: const LinearGradient(colors: [ + Color.fromARGB(0, 255, 255, 255), + Color.fromARGB(255, 255, 255, 255) + ], begin: Alignment.topCenter, end: Alignment.bottomCenter) + .createShader(const Rect.fromLTWH(0, 108, 0, 10)), + blendMode: BlendMode.modulate, + maskRect: const Rect.fromLTWH(0, 0, 99999, 140)); + + context.pushLayer(layer!, super.paint, offset); + } else { + layer = null; + } + } +} diff --git a/lib/components/PlayerScreen/queue_list_item.dart b/lib/components/PlayerScreen/queue_list_item.dart index 5aa441bea..fcb674db7 100644 --- a/lib/components/PlayerScreen/queue_list_item.dart +++ b/lib/components/PlayerScreen/queue_list_item.dart @@ -2,6 +2,7 @@ import 'package:finamp/components/AlbumScreen/song_menu.dart'; import 'package:finamp/components/album_image.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; +import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:finamp/services/process_artist.dart'; import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart' hide ReorderableList; @@ -56,6 +57,7 @@ class _QueueListItemState extends State return Dismissible( key: Key(widget.item.id), + direction: FinampSettingsHelper.finampSettings.disableGesture ? DismissDirection.none : DismissDirection.horizontal, onDismissed: (direction) async { Vibrate.feedback(FeedbackType.impact); await _queueService.removeAtOffset(widget.indexOffset); @@ -132,9 +134,9 @@ class _QueueListItemState extends State alignment: Alignment.centerRight, margin: const EdgeInsets.only(right: 8.0), padding: const EdgeInsets.only(right: 6.0), - width: widget.allowReorder + width: (widget.allowReorder ? 72.0 - : 42.0, //TODO make this responsive + : 42.0) + (FinampSettingsHelper.finampSettings.disableGesture ? 32 : 0), //TODO make this responsive child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, @@ -147,12 +149,27 @@ class _QueueListItemState extends State color: Theme.of(context).textTheme.bodySmall?.color, ), ), + if (FinampSettingsHelper.finampSettings.disableGesture) + IconButton( + padding: const EdgeInsets.only(left: 6.0), + visualDensity: VisualDensity.compact, + icon: const Icon( + TablerIcons.x, + color: Colors.white, + weight: 1.5, + ), + iconSize: 24.0, + onPressed: () async { + Vibrate.feedback(FeedbackType.light); + await _queueService.removeAtOffset(widget.indexOffset); + }, + ), if (widget.allowReorder) ReorderableDragStartListener( index: widget.listIndex, child: Padding( padding: - const EdgeInsets.only(bottom: 2.0, left: 6.0), + const EdgeInsets.only(left: 6.0), child: Icon( TablerIcons.grip_horizontal, color: Theme.of(context) diff --git a/lib/components/PlayerScreen/song_name_content.dart b/lib/components/PlayerScreen/song_name_content.dart index 9d4b0129e..751be7090 100644 --- a/lib/components/PlayerScreen/song_name_content.dart +++ b/lib/components/PlayerScreen/song_name_content.dart @@ -36,9 +36,10 @@ class SongNameContent extends StatelessWidget { child: BalancedText( currentTrack.item.title, textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( fontSize: 20, height: 26 / 20, + fontWeight: Theme.of(context).brightness == Brightness.light ? FontWeight.w500 : FontWeight.w600, ), overflow: TextOverflow.ellipsis, softWrap: true, diff --git a/lib/components/now_playing_bar.dart b/lib/components/now_playing_bar.dart index adb9f876a..cb8ab123d 100644 --- a/lib/components/now_playing_bar.dart +++ b/lib/components/now_playing_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:audio_service/audio_service.dart'; import 'package:finamp/color_schemes.g.dart'; import 'package:finamp/components/favourite_button.dart'; @@ -224,7 +226,7 @@ class NowPlayingBar extends ConsumerWidget { child: IconButton( onPressed: () { Vibrate.feedback( - FeedbackType.success); + FeedbackType.light); audioHandler.togglePlayback(); }, icon: mediaState.playbackState.playing @@ -257,7 +259,7 @@ class NowPlayingBar extends ConsumerWidget { MediaQuery.of(context).size; return Container( // rather hacky workaround, using LayoutBuilder would be nice but I couldn't get it to work... - width: (screenSize.width - + width: max(0, (screenSize.width - 2 * horizontalPadding - albumImageSize) * (playbackPosition! @@ -266,7 +268,7 @@ class NowPlayingBar extends ConsumerWidget { ?.duration ?? const Duration( seconds: 0)) - .inMilliseconds), + .inMilliseconds)), height: 70.0, decoration: ShapeDecoration( color: IconTheme.of(context) diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart index e0ed38b0f..faf6e8331 100644 --- a/lib/screens/music_screen.dart +++ b/lib/screens/music_screen.dart @@ -1,4 +1,5 @@ import 'package:finamp/services/queue_service.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -289,6 +290,8 @@ class _MusicScreenState extends ConsumerState ), body: TabBarView( controller: _tabController, + physics: FinampSettingsHelper.finampSettings.disableGesture ? const NeverScrollableScrollPhysics() : const AlwaysScrollableScrollPhysics(), + dragStartBehavior: DragStartBehavior.down, children: tabs .map((tabType) => MusicScreenTabView( tabContentType: tabType, diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index b10320bf5..a4ba55959 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -7,20 +7,13 @@ import 'dart:ui'; import 'package:audio_service/audio_service.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:finamp/services/offline_listen_helper.dart'; import 'package:get_it/get_it.dart'; -import 'package:finamp/models/finamp_models.dart'; -import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import 'package:logging/logging.dart'; -import 'audio_service_helper.dart'; import 'finamp_settings_helper.dart'; -import 'finamp_user_helper.dart'; -import 'jellyfin_api_helper.dart'; import 'locale_helper.dart'; import 'android_auto_helper.dart'; @@ -50,6 +43,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { Duration _sleepTimerDuration = Duration.zero; DateTime _sleepTimerStartTime = DateTime.now(); + /// 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. @@ -136,9 +130,18 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { }); // Special processing for state transitions. - _player.processingStateStream.listen((event) { + _player.processingStateStream.listen((event) async { if (event == ProcessingState.completed) { - stop(); + try { + _audioServiceBackgroundTaskLogger.info("Queue completed."); + // A full stop will trigger a re-shuffle with an unshuffled first + // item, so only pause. + await pause(); + await skipToIndex(0); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } } }); @@ -207,7 +210,6 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // Stop playing audio. await _player.stop(); - mediaItem.add(null); playbackState.add(playbackState.value .copyWith(processingState: AudioProcessingState.completed)); @@ -243,6 +245,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { doSkip = _player.position.inSeconds < 5; } + // This can only be true if on first track while loop mode is off if (!_player.hasPrevious) { await _player.seek(Duration.zero); } else { @@ -267,7 +270,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { @override Future skipToNext() async { try { - if (_player.loopMode == LoopMode.one && _player.hasNext) { + if (_player.loopMode == LoopMode.one || !_player.hasNext) { // if the user manually skips to the next track, they probably want to actually skip to the next track await skipByOffset( 1); //!!! don't use _player.nextIndex here, because that adjusts based on loop mode @@ -286,12 +289,28 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { _audioServiceBackgroundTaskLogger.fine("skipping by offset: $offset"); try { + int queueIndex = _player.shuffleModeEnabled + ? _queueAudioSource.shuffleIndices + .indexOf((_player.currentIndex ?? 0)) + + offset + : (_player.currentIndex ?? 0) + offset; + if (queueIndex >= (_player.effectiveIndices?.length ?? 1)) { + if (_player.loopMode == LoopMode.off) { + await _player.stop(); + } + queueIndex %= (_player.effectiveIndices?.length ?? 1); + } + if (queueIndex < 0) { + if (_player.loopMode == LoopMode.off) { + queueIndex = 0; + } else { + queueIndex %= (_player.effectiveIndices?.length ?? 1); + } + } await _player.seek(Duration.zero, index: _player.shuffleModeEnabled - ? _queueAudioSource.shuffleIndices[_queueAudioSource - .shuffleIndices - .indexOf(_player.currentIndex ?? 0) + offset] - : (_player.currentIndex ?? 0) + offset); + ? _queueAudioSource.shuffleIndices[queueIndex] + : queueIndex); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); @@ -523,7 +542,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { if (effectiveLufs != null) { final gainChange = (FinampSettingsHelper .finampSettings.replayGainTargetLufs - - effectiveLufs!) * + effectiveLufs) * FinampSettingsHelper.finampSettings.replayGainNormalizationFactor; _replayGainLogger.info( "Gain change: ${FinampSettingsHelper.finampSettings.replayGainTargetLufs - effectiveLufs} (raw), $gainChange (adjusted)"); @@ -617,7 +636,11 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, - queueIndex: _player.shuffleModeEnabled && (shuffleIndices?.isNotEmpty ?? false) && event.currentIndex != null ? shuffleIndices!.indexOf(event.currentIndex!) : event.currentIndex, + queueIndex: _player.shuffleModeEnabled && + (shuffleIndices?.isNotEmpty ?? false) && + event.currentIndex != null + ? shuffleIndices!.indexOf(event.currentIndex!) + : event.currentIndex, shuffleMode: _player.shuffleModeEnabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, diff --git a/lib/services/playback_history_service.dart b/lib/services/playback_history_service.dart index a5cecd455..37ee22419 100644 --- a/lib/services/playback_history_service.dart +++ b/lib/services/playback_history_service.dart @@ -38,7 +38,9 @@ class PlaybackHistoryService { PlaybackHistoryService() { _queueService.getCurrentTrackStream().listen((currentTrack) { - updateCurrentTrack(currentTrack); + if (_audioService.playbackState.valueOrNull?.processingState != AudioProcessingState.completed) { + updateCurrentTrack(currentTrack); + } if (currentTrack == null) { _reportPlaybackStopped(); @@ -62,9 +64,14 @@ class PlaybackHistoryService { final currentItem = _queueService.getCurrentTrack(); if (currentIndex != null && currentItem != null) { + // differences in queue index or item id are considered track changes if (currentItem.id != prevItem?.id || (_reportQueueToServer && currentIndex != prevState?.queueIndex)) { + if (currentState.playing != prevState?.playing) { + // add to playback history if playback was stopped before + updateCurrentTrack(currentItem, forceNewTrack: true); + } _playbackHistoryServiceLogger.fine( "Reporting track change event from ${prevItem?.item.title} to ${currentItem.item.title}"); //TODO handle reporting track changes based on history changes, as that is more reliable From 5797b16c40e4183be156c708db8bf4591e334cb4 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 24 Mar 2024 00:01:46 +0100 Subject: [PATCH 33/42] disable artwork preloading in media session due to issues with custom content provider --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 2614d89b7..2db2fb195 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -251,7 +251,7 @@ Future _setupPlaybackServices() async { androidNotificationIcon: "mipmap/white", androidNotificationChannelId: "com.unicornsonlsd.finamp.audio", // notificationColor: TODO use the theme color for older versions of Android, - preloadArtwork: true, + preloadArtwork: false, androidBrowsableRootExtras: { "android.media.browse.SEARCH_SUPPORTED" : true, // support showing search button on Android Auto as well as alternative search results on the player screen after voice search // see https://developer.android.com/reference/androidx/media/utils/MediaConstants#DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM() From f37784ddd37ba45500f2003a7fadc40f3d5aa9d4 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 26 May 2024 22:01:08 +0200 Subject: [PATCH 34/42] added offline support for voice commands --- .../AlbumScreen/song_list_tile.dart | 6 +- lib/services/android_auto_helper.dart | 286 +++++++++++++----- lib/services/jellyfin_api.dart | 3 +- 3 files changed, 217 insertions(+), 78 deletions(-) diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index c9824bf50..9b40b5163 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -303,10 +303,8 @@ class _SongListTileState extends ConsumerState (element) => element.id == widget.item.id) : await widget.index, source: QueueItemSource( - name: QueueItemSourceName( - type: QueueItemSourceNameType.preTranslated, - pretranslatedName: - AppLocalizations.of(context)!.placeholderSource), + name: const QueueItemSourceName( + type: QueueItemSourceNameType.mix), type: QueueItemSourceType.allSongs, id: widget.item.id, ), diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index f09c570f0..e570368ca 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -2,8 +2,10 @@ import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; +import 'package:finamp/components/global_snackbar.dart'; import 'package:finamp/services/downloads_service.dart'; import 'package:get_it/get_it.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:finamp/models/finamp_models.dart'; @@ -172,22 +174,18 @@ class AndroidAutoHelper { List? searchResultExactQuery; List? searchResultAdjustedQuery; try { - searchResultExactQuery = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: TabContentType.songs.itemType.idString, + searchResultExactQuery = await _getResults( searchTerm: searchQuery.query.trim(), - startIndex: 0, + itemType: TabContentType.songs.itemType, limit: 7, ); } catch (e) { _androidAutoHelperLogger.severe("Error while searching for exact query:", e); } try { - searchResultAdjustedQuery = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: TabContentType.songs.itemType.idString, + searchResultExactQuery = await _getResults( searchTerm: searchQuery.extras!["android.intent.extra.title"].trim(), - startIndex: 0, + itemType: TabContentType.songs.itemType, limit: (searchResultExactQuery != null && searchResultExactQuery.isNotEmpty) ? 13 : 20, ); } catch (e) { @@ -197,11 +195,9 @@ class AndroidAutoHelper { searchResult = searchResultExactQuery?.followedBy(searchResultAdjustedQuery ?? []).toList() ?? []; } else { - searchResult = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: TabContentType.songs.itemType.idString, + searchResult = await _getResults( searchTerm: searchQuery.query.trim(), - startIndex: 0, + itemType: TabContentType.songs.itemType, limit: 20, ); } @@ -262,21 +258,21 @@ class AndroidAutoHelper { return await shuffleAllSongs(); } - String? itemType = TabContentType.songs.itemType.idString; + BaseItemDtoType? itemType = TabContentType.songs.itemType; String? alternativeQuery; bool searchForPlaylists = false; if (searchQuery.extras?["android.intent.extra.album"] != null && searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] != null) { // if all metadata is provided, search for song - itemType = TabContentType.songs.itemType.idString; + itemType = TabContentType.songs.itemType; alternativeQuery = searchQuery.extras?["android.intent.extra.title"]; } else if (searchQuery.extras?["android.intent.extra.album"] != null && searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] == null) { // if only album is provided, search for album - itemType = TabContentType.albums.itemType.idString; + itemType = TabContentType.albums.itemType; alternativeQuery = searchQuery.extras?["android.intent.extra.album"]; } else if (searchQuery.extras?["android.intent.extra.artist"] != null && searchQuery.extras?["android.intent.extra.title"] == null) { // if only artist is provided, search for artist - itemType = TabContentType.artists.itemType.idString; + itemType = TabContentType.artists.itemType; alternativeQuery = searchQuery.extras?["android.intent.extra.artist"]; } else { // if no metadata is provided, search for song *and* playlists, preferring playlists @@ -285,30 +281,61 @@ class AndroidAutoHelper { _androidAutoHelperLogger.info("Searching for: $itemType that matches query '${alternativeQuery ?? searchQuery.query}'${searchForPlaylists ? ", including (and preferring) playlists" : ""}"); + final searchTerm = alternativeQuery?.trim() ?? searchQuery.query.trim(); + if (searchForPlaylists) { try { - List? searchResult = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: TabContentType.playlists.itemType.idString, - searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), - startIndex: 0, - limit: 1, - ); + List? searchResult; - final playlist = searchResult![0]; - final items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); - _androidAutoHelperLogger.info("Playing playlist: ${playlist.name} (${items?.length} songs)"); + if (FinampSettingsHelper.finampSettings.isOffline) { + List? offlineItems = await _downloadService.getAllCollections( + nameFilter: searchTerm, + baseTypeFilter: TabContentType.playlists.itemType, + fullyDownloaded: false, + viewFilter: finampUserHelper.currentUser?.currentView?.id, + childViewFilter: null, + nullableViewFilters: FinampSettingsHelper.finampSettings.showDownloadsWithUnknownLibrary, + onlyFavorites: false); + + searchResult = offlineItems.map((e) => e.baseItem).whereNotNull().toList(); + } else { + searchResult = await jellyfinApiHelper.getItems( + parentItem: null, // always use global playlists + includeItemTypes: TabContentType.playlists.itemType.idString, + searchTerm: searchTerm, + startIndex: 0, + limit: 1, + ); + } - await queueService.startPlayback(items: items ?? [], source: QueueItemSource( - type: QueueItemSourceType.playlist, - name: QueueItemSourceName( - type: QueueItemSourceNameType.preTranslated, - pretranslatedName: playlist.name), - id: playlist.id, - item: playlist, - ), - order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? - ); + if (searchResult?.isNotEmpty ?? false) { + + final playlist = searchResult![0]; + + List? items; + + if (FinampSettingsHelper.finampSettings.isOffline) { + items = await _downloadService.getCollectionSongs(playlist, playable: true); + } else { + items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + } + + _androidAutoHelperLogger.info("Playing playlist: ${playlist.name} (${items?.length} songs)"); + + await queueService.startPlayback(items: items ?? [], source: QueueItemSource( + type: QueueItemSourceType.playlist, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: playlist.name), + id: playlist.id, + item: playlist, + ), + order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? + ); + + } else { + _androidAutoHelperLogger.warning("No playlists found for query: ${alternativeQuery ?? searchQuery.query}"); + } } catch (e) { _androidAutoHelperLogger.warning("Couldn't search for playlists: $e"); @@ -316,12 +343,10 @@ class AndroidAutoHelper { } try { - List? searchResult = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: itemType, - searchTerm: alternativeQuery?.trim() ?? searchQuery.query.trim(), - startIndex: 0, - limit: 25, // get more than the first result so we can filter using additional metadata + + List? searchResult = await _getResults( + searchTerm: searchTerm, + itemType: itemType, ); if (searchResult == null || searchResult.isEmpty) { @@ -329,12 +354,9 @@ class AndroidAutoHelper { if (alternativeQuery != null) { // try again with metadata provided by android (could be corrected based on metadata or localizations) - searchResult = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: itemType, + searchResult = await _getResults( searchTerm: alternativeQuery.trim(), - startIndex: 0, - limit: 25, // get more than the first result so we can filter using additional metadata + itemType: itemType, ); } @@ -345,9 +367,9 @@ class AndroidAutoHelper { } final selectedResult = searchResult.firstWhere((element) { - if (itemType == TabContentType.songs.itemType.idString && searchQuery.extras?["android.intent.extra.artist"] != null) { + if (itemType == TabContentType.songs.itemType && searchQuery.extras?["android.intent.extra.artist"] != null) { return element.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (searchQuery.extras?["android.intent.extra.artist"]?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false; - } else if (itemType == TabContentType.songs.itemType.idString && searchQuery.extras?["android.intent.extra.artist"] != null) { + } else if (itemType == TabContentType.songs.itemType && searchQuery.extras?["android.intent.extra.artist"] != null) { return element.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (searchQuery.extras?["android.intent.extra.artist"]?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false; } else { return false; @@ -357,9 +379,15 @@ class AndroidAutoHelper { _androidAutoHelperLogger.info("Playing from search: ${selectedResult.name}"); - if (itemType == TabContentType.albums.itemType.idString) { - final album = await _jellyfinApiHelper.getItemById(selectedResult.id); - final items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + if (itemType == TabContentType.albums.itemType) { + final album = selectedResult; + List? items; + + if (FinampSettingsHelper.finampSettings.isOffline) { + items = await _downloadService.getCollectionSongs(album, playable: true); + } else { + items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); + } _androidAutoHelperLogger.info("Playing album: ${album.name} (${items?.length} songs)"); await queueService.startPlayback(items: items ?? [], source: QueueItemSource( @@ -372,9 +400,9 @@ class AndroidAutoHelper { ), order: FinampPlaybackOrder.linear, //TODO add a setting that sets the default (because Android Auto doesn't give use the prompt as an extra), or use the current order? ); - } else if (itemType == TabContentType.artists.itemType.idString) { + } else if (itemType == TabContentType.artists.itemType) { if (FinampSettingsHelper.finampSettings.isOffline) { - final parentBaseItems = await getBaseItems(MediaItemId(contentType: TabContentType.songs, parentType: MediaItemParentType.collection, parentId: selectedResult.id)); + final parentBaseItems = await getBaseItems(MediaItemId(contentType: TabContentType.artists, parentType: MediaItemParentType.collection, parentId: selectedResult.id, itemId: selectedResult.id)); await queueService.startPlayback( items: parentBaseItems, @@ -392,7 +420,41 @@ class AndroidAutoHelper { await audioServiceHelper.startInstantMixForArtists([selectedResult]).then((value) => 1); } } else { - await audioServiceHelper.startInstantMixForItem(selectedResult).then((value) => 1); + if (FinampSettingsHelper.finampSettings.isOffline) { + List offlineItems; + // If we're on the songs tab, just get all of the downloaded items + offlineItems = await _downloadService.getAllSongs( + // nameFilter: widget.searchTerm, + viewFilter: finampUserHelper.currentUser?.currentView?.id, + nullableViewFilters: + FinampSettingsHelper.finampSettings.showDownloadsWithUnknownLibrary); + + var items = offlineItems + .map((e) => e.baseItem) + .whereNotNull() + .toList(); + + items = _sortItems( + items, + FinampSettingsHelper.finampSettings.tabSortBy[TabContentType.songs]!, + FinampSettingsHelper.finampSettings.tabSortOrder[TabContentType.songs]!); + + final indexOfSelected = items.indexWhere((element) => element.id == selectedResult.id); + + return await queueService.startPlayback( + items: items, + startingIndex: indexOfSelected, + source: QueueItemSource( + name: const QueueItemSourceName( + type: QueueItemSourceNameType.mix), + type: QueueItemSourceType.allSongs, + id: selectedResult.id, + ), + ); + + } else { + await audioServiceHelper.startInstantMixForItem(selectedResult).then((value) => 1); + } } } catch (err) { _androidAutoHelperLogger.severe("Error while playing from search query: $err"); @@ -420,6 +482,7 @@ class AndroidAutoHelper { Future playFromMediaId(MediaItemId itemId) async { final audioServiceHelper = GetIt.instance(); + final finampUserHelper = GetIt.instance(); // queue service should be initialized by time we get here final queueService = GetIt.instance(); @@ -430,7 +493,40 @@ class AndroidAutoHelper { } if (itemId.parentType == MediaItemParentType.instantMix) { - return await audioServiceHelper.startInstantMixForItem(await _jellyfinApiHelper.getItemById(itemId.itemId!)); + if (FinampSettingsHelper.finampSettings.isOffline) { + List offlineItems; + // If we're on the songs tab, just get all of the downloaded items + offlineItems = await _downloadService.getAllSongs( + // nameFilter: widget.searchTerm, + viewFilter: finampUserHelper.currentUser?.currentView?.id, + nullableViewFilters: + FinampSettingsHelper.finampSettings.showDownloadsWithUnknownLibrary); + + var items = offlineItems + .map((e) => e.baseItem) + .whereNotNull() + .toList(); + + items = _sortItems( + items, + FinampSettingsHelper.finampSettings.tabSortBy[TabContentType.songs]!, + FinampSettingsHelper.finampSettings.tabSortOrder[TabContentType.songs]!); + + final indexOfSelected = items.indexWhere((element) => element.id == itemId.itemId); + + return await queueService.startPlayback( + items: items, + startingIndex: indexOfSelected, + source: QueueItemSource( + name: const QueueItemSourceName( + type: QueueItemSourceNameType.mix), + type: QueueItemSourceType.allSongs, + id: itemId.itemId!, + ), + ); + } else { + return await audioServiceHelper.startInstantMixForItem(await _jellyfinApiHelper.getItemById(itemId.itemId!)); + } } if (itemId.parentType != MediaItemParentType.collection || itemId.itemId == null) { @@ -457,9 +553,9 @@ class AndroidAutoHelper { ), order: FinampPlaybackOrder.linear, ); + } else { + return await audioServiceHelper.startInstantMixForArtists([parentItem]); } - - return await audioServiceHelper.startInstantMixForArtists([parentItem]); } final parentBaseItems = await getBaseItems(itemId); @@ -476,18 +572,68 @@ class AndroidAutoHelper { )); } + Future?> _getResults({ + required String searchTerm, + required BaseItemDtoType itemType, + int limit = 25, + }) async { + final jellyfinApiHelper = GetIt.instance(); + final finampUserHelper = GetIt.instance(); + List? searchResult; + + if (FinampSettingsHelper.finampSettings.isOffline) { + + List offlineItems; + + if (itemType == TabContentType.songs.itemType) { + // If we're on the songs tab, just get all of the downloaded items + // We should probably try to page this, at least if we are sorting by name + offlineItems = await _downloadService.getAllSongs( + nameFilter: searchTerm, + viewFilter: finampUserHelper.currentUser?.currentView?.id, + nullableViewFilters: FinampSettingsHelper.finampSettings.showDownloadsWithUnknownLibrary, + onlyFavorites: false); + } else { + offlineItems = await _downloadService.getAllCollections( + nameFilter: searchTerm, + baseTypeFilter: itemType, + fullyDownloaded: false, + viewFilter: itemType == TabContentType.albums.itemType + ? finampUserHelper.currentUser?.currentView?.id + : null, + childViewFilter: (itemType != TabContentType.albums.itemType && + itemType != TabContentType.playlists.itemType) + ? finampUserHelper.currentUser?.currentView?.id + : null, + nullableViewFilters: itemType == TabContentType.albums.itemType && + FinampSettingsHelper.finampSettings.showDownloadsWithUnknownLibrary, + onlyFavorites: false); + } + searchResult = offlineItems.map((e) => e.baseItem).whereNotNull().toList(); + + } else { + searchResult = await jellyfinApiHelper.getItems( + parentItem: finampUserHelper.currentUser?.currentView, + includeItemTypes: itemType.idString, + searchTerm: searchTerm, + startIndex: 0, + limit: limit, // get more than the first result so we can filter using additional metadata + ); + } + + return searchResult; + } + // sort items - List _sortItems(List items, SortBy sortBy, SortOrder sortOrder) { - items.sort((a, b) { - switch (sortBy) { + List _sortItems(List itemsToSort, SortBy? sortBy, SortOrder? sortOrder) { + itemsToSort.sort((a, b) { + switch (sortBy ?? SortBy.sortName) { case SortBy.sortName: - final aName = a.name?.trim().toLowerCase(); - final bName = b.name?.trim().toLowerCase(); - if (aName == null || bName == null) { + if (a.nameForSorting == null || b.nameForSorting == null) { // Returning 0 is the same as both being the same return 0; } else { - return aName.compareTo(bName); + return a.nameForSorting!.compareTo(b.nameForSorting!); } case SortBy.albumArtist: if (a.albumArtist == null || b.albumArtist == null) { @@ -530,13 +676,9 @@ class AndroidAutoHelper { } }); - if (sortOrder == SortOrder.descending) { - // The above sort functions sort in ascending order, so we swap them - // when sorting in descending order. - items = items.reversed.toList(); - } - - return items; + return sortOrder == SortOrder.descending + ? itemsToSort.reversed.toList() + : itemsToSort; } // albums, playlists, and songs should play when clicked diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart index 622a6440e..6b4a43e42 100644 --- a/lib/services/jellyfin_api.dart +++ b/lib/services/jellyfin_api.dart @@ -461,8 +461,7 @@ abstract class JellyfinApi extends ChopperService { final client = ChopperClient( client: http.IOClient(HttpClient() ..connectionTimeout = const Duration( - seconds: - 8) // if we don't get a response by then, it's probably not worth it to wait any longer. this prevents the server connection test from taking too long + seconds: 10) // if we don't get a response by then, it's probably not worth it to wait any longer. this prevents the server connection test from taking too long ), // The first part of the URL is now here services: [ From 895fe77c1f18f44219f51aabec18eab7b26ae2c2 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 26 May 2024 22:26:06 +0200 Subject: [PATCH 35/42] explicitly handle request for recent ("For you") root --- lib/services/android_auto_helper.dart | 6 ++--- .../music_player_background_task.dart | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index e570368ca..4e4e80d53 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -144,7 +144,7 @@ class AndroidAutoHelper { Future> getRecentItems() async { final queueService = GetIt.instance(); - + //TODO this should ideally list recent queues (the restorable ones), or items from the home screen try { final recentItems = queueService.peekQueue(previous: 5); final List recentMediaItems = []; @@ -702,10 +702,10 @@ class AndroidAutoHelper { itemId: item.id, ); - final downloadedSong = await _downloadService.getSongDownload(item: item); + final downloadedSong = _downloadService.getSongDownload(item: item); DownloadItem? downloadedImage; try { - downloadedImage = await _downloadService.getImageDownload(item: item); + downloadedImage = _downloadService.getImageDownload(item: item); } catch (e) { _androidAutoHelperLogger.warning("Couldn't get the offline image for track '${item.name}' because it's not downloaded or missing a blurhash"); } diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index b531484bc..462f82904 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -478,19 +478,22 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { return _getRootMenu(); } - // else if (parentMediaId == AudioService.recentRootId) { - // return await _androidAutoHelper.getRecentItems(); - // } - - try { - final itemId = MediaItemId.fromJson(jsonDecode(parentMediaId)); - - return await _androidAutoHelper.getMediaItems(itemId); - - } catch (e) { - _audioServiceBackgroundTaskLogger.severe(e); - return super.getChildren(parentMediaId); + else if (parentMediaId == AudioService.recentRootId) { + // return await _androidAutoHelper.getRecentItems(); + // return playlists for now + return await _androidAutoHelper.getMediaItems(MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection)); + } + try { + final itemId = MediaItemId.fromJson(jsonDecode(parentMediaId)); + + return await _androidAutoHelper.getMediaItems(itemId); + + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return super.getChildren(parentMediaId); + } } + } // play specific item From 289f3b27288a8c4e08985e87dab496def6c1dfdd Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Sun, 26 May 2024 23:04:01 +0200 Subject: [PATCH 36/42] fix syntax error --- lib/services/music_player_background_task.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 462f82904..65db957bd 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -482,7 +482,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // return await _androidAutoHelper.getRecentItems(); // return playlists for now return await _androidAutoHelper.getMediaItems(MediaItemId(contentType: TabContentType.playlists, parentType: MediaItemParentType.rootCollection)); - } + } else { try { final itemId = MediaItemId.fromJson(jsonDecode(parentMediaId)); From adf2a81922eb253a26841db11ae076f17e06da90 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Mon, 27 May 2024 22:20:50 +0200 Subject: [PATCH 37/42] improvements based on review --- .../MusicScreen/music_screen_tab_view.dart | 1 - lib/components/PlayerScreen/queue_list.dart | 38 ---- lib/gen/assets.gen.dart | 114 +++++++++++ lib/main.dart | 73 +++---- lib/models/finamp_models.dart | 5 + lib/services/android_auto_helper.dart | 187 ++++-------------- .../music_player_background_task.dart | 47 +++-- lib/services/queue_service.dart | 59 ++++-- pubspec.lock | 64 ++++++ pubspec.yaml | 1 + 10 files changed, 339 insertions(+), 250 deletions(-) create mode 100644 lib/gen/assets.gen.dart diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart index a17efbb1a..eb6dbd21f 100644 --- a/lib/components/MusicScreen/music_screen_tab_view.dart +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -57,7 +57,6 @@ class _MusicScreenTabViewState extends State final _jellyfinApiHelper = GetIt.instance(); final _isarDownloader = GetIt.instance(); - final _finampUserHelper = GetIt.instance(); StreamSubscription? _refreshStream; late AutoScrollController controller; diff --git a/lib/components/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart index 403a66d4b..374b683c7 100644 --- a/lib/components/PlayerScreen/queue_list.dart +++ b/lib/components/PlayerScreen/queue_list.dart @@ -1264,41 +1264,3 @@ class PreviousTracksSectionHeader extends SliverPersistentHeaderDelegate { @override bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; } - -class QueueTracksMask extends SingleChildRenderObjectWidget { - const QueueTracksMask({ - super.key, - super.child, - }); - - @override - RenderQueueTracksMask createRenderObject(BuildContext context) { - return RenderQueueTracksMask(); - } -} - -class RenderQueueTracksMask extends RenderProxySliver { - @override - ShaderMaskLayer? get layer => super.layer as ShaderMaskLayer?; - - @override - bool get alwaysNeedsCompositing => child != null; - - @override - void paint(PaintingContext context, Offset offset) { - if (child != null) { - layer ??= ShaderMaskLayer( - shader: const LinearGradient(colors: [ - Color.fromARGB(0, 255, 255, 255), - Color.fromARGB(255, 255, 255, 255) - ], begin: Alignment.topCenter, end: Alignment.bottomCenter) - .createShader(const Rect.fromLTWH(0, 108, 0, 10)), - blendMode: BlendMode.modulate, - maskRect: const Rect.fromLTWH(0, 0, 99999, 140)); - - context.pushLayer(layer!, super.paint, offset); - } else { - layer = null; - } - } -} diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart new file mode 100644 index 000000000..6449e43d0 --- /dev/null +++ b/lib/gen/assets.gen.dart @@ -0,0 +1,114 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use + +import 'package:flutter/widgets.dart'; + +class $ImagesGen { + const $ImagesGen(); + + /// File path: images/album_white.png + AssetGenImage get albumWhite => const AssetGenImage('images/album_white.png'); + + /// File path: images/finamp.png + AssetGenImage get finamp => const AssetGenImage('images/finamp.png'); + + /// File path: images/finamp_cropped.png + AssetGenImage get finampCropped => + const AssetGenImage('images/finamp_cropped.png'); + + /// File path: images/jellyfin-icon-transparent.png + AssetGenImage get jellyfinIconTransparent => + const AssetGenImage('images/jellyfin-icon-transparent.png'); + + /// List of all assets + List get values => + [albumWhite, finamp, finampCropped, jellyfinIconTransparent]; +} + +class Assets { + Assets._(); + + static const $ImagesGen images = $ImagesGen(); +} + +class AssetGenImage { + const AssetGenImage(this._assetName, {this.size = null}); + + final String _assetName; + + final Size? size; + + Image image({ + Key? key, + AssetBundle? bundle, + ImageFrameBuilder? frameBuilder, + ImageErrorWidgetBuilder? errorBuilder, + String? semanticLabel, + bool excludeFromSemantics = false, + double? scale, + double? width, + double? height, + Color? color, + Animation? opacity, + BlendMode? colorBlendMode, + BoxFit? fit, + AlignmentGeometry alignment = Alignment.center, + ImageRepeat repeat = ImageRepeat.noRepeat, + Rect? centerSlice, + bool matchTextDirection = false, + bool gaplessPlayback = false, + bool isAntiAlias = false, + String? package, + FilterQuality filterQuality = FilterQuality.low, + int? cacheWidth, + int? cacheHeight, + }) { + return Image.asset( + _assetName, + key: key, + bundle: bundle, + frameBuilder: frameBuilder, + errorBuilder: errorBuilder, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + scale: scale, + width: width, + height: height, + color: color, + opacity: opacity, + colorBlendMode: colorBlendMode, + fit: fit, + alignment: alignment, + repeat: repeat, + centerSlice: centerSlice, + matchTextDirection: matchTextDirection, + gaplessPlayback: gaplessPlayback, + isAntiAlias: isAntiAlias, + package: package, + filterQuality: filterQuality, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + ); + } + + ImageProvider provider({ + AssetBundle? bundle, + String? package, + }) { + return AssetImage( + _assetName, + bundle: bundle, + package: package, + ); + } + + String get path => _assetName; + + String get keyName => _assetName; +} diff --git a/lib/main.dart b/lib/main.dart index 05ce8b06a..87867bd0e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:finamp/color_schemes.g.dart'; +import 'package:finamp/gen/assets.gen.dart'; import 'package:finamp/screens/downloads_settings_screen.dart'; import 'package:finamp/screens/interaction_settings_screen.dart'; import 'package:finamp/screens/login_screen.dart'; @@ -81,6 +82,7 @@ void main() async { await _setupJellyfinApiData(); _setupOfflineListenLogHelper(); await _setupDownloadsHelper(); + await _setupOSIntegration(); await _setupPlaybackServices(); } catch (error, trace) { hasFailed = true; @@ -112,39 +114,6 @@ void main() async { : "en_US"; await initializeDateFormatting(localeString, null); - // Load the album image from assets and save it to the documents directory for use in Android Auto - final documentsDirectory = await getApplicationDocumentsDirectory(); - final albumImageFile = File('${documentsDirectory.absolute.path}/images/album_white.png'); - if (!(await albumImageFile.exists())) { - final albumImageBytes = await rootBundle.load("images/album_white.png"); - final albumBuffer = albumImageBytes.buffer; - await albumImageFile.create(recursive: true); - await albumImageFile.writeAsBytes( - albumBuffer.asUint8List( - albumImageBytes.offsetInBytes, - albumImageBytes.lengthInBytes, - ), - ); - } - - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { - WidgetsFlutterBinding.ensureInitialized(); - await windowManager.ensureInitialized(); - WindowOptions windowOptions = const WindowOptions( - size: Size(1200, 800), - center: true, - backgroundColor: Colors.transparent, - skipTaskbar: false, - titleBarStyle: TitleBarStyle.normal, - minimumSize: Size(400, 250), - ); - unawaited( - WindowManager.instance.waitUntilReadyToShow(windowOptions, () async { - await windowManager.show(); - await windowManager.focus(); - })); - } - runApp(const Finamp()); } } @@ -278,6 +247,44 @@ Future setupHive() async { GetIt.instance.registerSingleton(isar); } +Future _setupOSIntegration() async { + + // set up window manager on desktop, mainly to restrict minimum size + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + WidgetsFlutterBinding.ensureInitialized(); + await windowManager.ensureInitialized(); + WindowOptions windowOptions = const WindowOptions( + size: Size(1200, 800), + center: true, + backgroundColor: Colors.transparent, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.normal, + minimumSize: Size(400, 250), + ); + unawaited( + WindowManager.instance.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + })); + } + + // Load the album image from assets and save it to the documents directory for use in Android Auto + final applicationSupportDirectory = await getApplicationSupportDirectory(); + final albumImageFile = File('${applicationSupportDirectory.path}/${Assets.images.albumWhite.path}'); + if (!(await albumImageFile.exists())) { + final albumImageBytes = await rootBundle.load(Assets.images.albumWhite.path); + final albumBuffer = albumImageBytes.buffer; + await albumImageFile.create(recursive: true); + await albumImageFile.writeAsBytes( + albumBuffer.asUint8List( + albumImageBytes.offsetInBytes, + albumImageBytes.lengthInBytes, + ), + ); + } + +} + Future _setupPlaybackServices() async { if (Platform.isWindows) { AudioServiceSMTC.registerWith(); diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index 2b1ffc46e..c05b0aa0a 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -441,6 +441,11 @@ class FinampSettings { } } +enum CustomPlaybackActions { + shuffle, + toggleFavorite; +} + /// Custom storage locations for storing music/images. @HiveType(typeId: 31) class DownloadLocation { diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 4e4e80d53..6880c6c88 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; +import 'package:finamp/components/MusicScreen/music_screen_tab_view.dart'; import 'package:finamp/components/global_snackbar.dart'; import 'package:finamp/services/downloads_service.dart'; import 'package:get_it/get_it.dart'; @@ -71,7 +72,7 @@ class AndroidAutoHelper { baseItems.add(downloadedParent.baseItem!); } } - return _sortItems(baseItems, sortBy, sortOrder); + return sortItems(baseItems, sortBy, sortOrder); } // use downloaded parent only in offline mode @@ -115,7 +116,7 @@ class AndroidAutoHelper { } // only sort items if we are not playing them - return _isPlayable(itemId.contentType) ? downloadedItems : _sortItems(downloadedItems, sortBy, sortOrder); + return _isPlayable(itemId.contentType) ? downloadedItems : sortItems(downloadedItems, sortBy, sortOrder); } } @@ -124,13 +125,35 @@ class AndroidAutoHelper { // fetch the online version if we can't get offline version // select the item type that each parent holds - final includeItemTypes = itemId.parentType == MediaItemParentType.collection // if we are browsing a root library. e.g. browsing the list of all albums or artists - ? (itemId.contentType == TabContentType.albums ? TabContentType.songs.itemType.idString // get an album's songs - : itemId.contentType == TabContentType.artists ? TabContentType.albums.itemType.idString // get an artist's albums - : itemId.contentType == TabContentType.playlists ? TabContentType.songs.itemType.idString // get a playlist's songs - : itemId.contentType == TabContentType.genres ? TabContentType.albums.itemType.idString // get a genre's albums - : TabContentType.songs.itemType.idString ) // if we don't have one of these categories, we are probably dealing with stray songs - : itemId.contentType.itemType.idString; // get the root library + String? includeItemTypes; + if (itemId.parentType == MediaItemParentType.collection) { + // if we are browsing a root library. e.g. browsing the list of all albums or artists + switch (itemId.contentType) { + case TabContentType.albums: + // get an album's songs + includeItemTypes = TabContentType.songs.itemType.idString; + break; + case TabContentType.artists: + // get an artist's albums + includeItemTypes = TabContentType.albums.itemType.idString; + break; + case TabContentType.playlists: + // get a playlist's songs + includeItemTypes = TabContentType.songs.itemType.idString; + break; + case TabContentType.genres: + // get a genre's albums + includeItemTypes = TabContentType.albums.itemType.idString; + break; + default: + // if we don't have one of these categories, we are probably dealing with stray songs + includeItemTypes = TabContentType.songs.itemType.idString; + break; + } + } else { + // get the root library + includeItemTypes = itemId.contentType.itemType.idString; + } // if parent id is defined, use that to get items. // otherwise, use the current view as fallback to ensure we get the correct items. @@ -150,7 +173,7 @@ class AndroidAutoHelper { final List recentMediaItems = []; for (final item in recentItems) { if (item.baseItem == null) continue; - final mediaItem = await _convertToMediaItem(item: item.baseItem!, parentType: MediaItemParentType.collection); + final mediaItem = await queueService.generateMediaItem(item.baseItem!, parentType: MediaItemParentType.collection, isPlayable: _isPlayable); recentMediaItems.add(mediaItem); } return recentMediaItems; @@ -161,8 +184,7 @@ class AndroidAutoHelper { } Future> searchItems(AndroidAutoSearchQuery searchQuery) async { - final jellyfinApiHelper = GetIt.instance(); - final finampUserHelper = GetIt.instance(); + final queueService = GetIt.instance(); try { @@ -241,7 +263,7 @@ class AndroidAutoHelper { return bMatchQuality.compareTo(aMatchQuality); }); - return [ for (final item in filteredSearchResults) await _convertToMediaItem(item: item, parentType: MediaItemParentType.instantMix, parentId: item.parentId) ]; + return [ for (final item in filteredSearchResults) await queueService.generateMediaItem(item, parentType: MediaItemParentType.instantMix, parentId: item.parentId, isPlayable: _isPlayable) ]; } catch (err) { _androidAutoHelperLogger.severe("Error while searching:", err); return []; @@ -434,7 +456,7 @@ class AndroidAutoHelper { .whereNotNull() .toList(); - items = _sortItems( + items = sortItems( items, FinampSettingsHelper.finampSettings.tabSortBy[TabContentType.songs]!, FinampSettingsHelper.finampSettings.tabSortOrder[TabContentType.songs]!); @@ -472,12 +494,8 @@ class AndroidAutoHelper { } Future> getMediaItems(MediaItemId itemId) async { - return [ for (final item in await getBaseItems(itemId)) await _convertToMediaItem(item: item, parentType: MediaItemParentType.collection, parentId: item.parentId) ]; - } - - Future toggleShuffle() async { final queueService = GetIt.instance(); - queueService.togglePlaybackOrder(); + return [ for (final item in await getBaseItems(itemId)) await queueService.generateMediaItem(item, parentType: MediaItemParentType.collection, parentId: item.parentId, isPlayable: _isPlayable) ]; } Future playFromMediaId(MediaItemId itemId) async { @@ -507,7 +525,7 @@ class AndroidAutoHelper { .whereNotNull() .toList(); - items = _sortItems( + items = sortItems( items, FinampSettingsHelper.finampSettings.tabSortBy[TabContentType.songs]!, FinampSettingsHelper.finampSettings.tabSortOrder[TabContentType.songs]!); @@ -624,138 +642,13 @@ class AndroidAutoHelper { return searchResult; } - // sort items - List _sortItems(List itemsToSort, SortBy? sortBy, SortOrder? sortOrder) { - itemsToSort.sort((a, b) { - switch (sortBy ?? SortBy.sortName) { - case SortBy.sortName: - if (a.nameForSorting == null || b.nameForSorting == null) { - // Returning 0 is the same as both being the same - return 0; - } else { - return a.nameForSorting!.compareTo(b.nameForSorting!); - } - case SortBy.albumArtist: - if (a.albumArtist == null || b.albumArtist == null) { - return 0; - } else { - return a.albumArtist!.compareTo(b.albumArtist!); - } - case SortBy.communityRating: - if (a.communityRating == null || - b.communityRating == null) { - return 0; - } else { - return a.communityRating!.compareTo(b.communityRating!); - } - case SortBy.criticRating: - if (a.criticRating == null || b.criticRating == null) { - return 0; - } else { - return a.criticRating!.compareTo(b.criticRating!); - } - case SortBy.dateCreated: - if (a.dateCreated == null || b.dateCreated == null) { - return 0; - } else { - return a.dateCreated!.compareTo(b.dateCreated!); - } - case SortBy.premiereDate: - if (a.premiereDate == null || b.premiereDate == null) { - return 0; - } else { - return a.premiereDate!.compareTo(b.premiereDate!); - } - case SortBy.random: - // We subtract the result by one so that we can get -1 values - // (see compareTo documentation) - return Random().nextInt(2) - 1; - default: - throw UnimplementedError( - "Unimplemented offline sort mode $sortBy"); - } - }); - - return sortOrder == SortOrder.descending - ? itemsToSort.reversed.toList() - : itemsToSort; - } - // albums, playlists, and songs should play when clicked // clicking artists starts an instant mix, so they are technically playable // genres has subcategories, so it should be browsable but not playable - bool _isPlayable(TabContentType tabContentType) { + bool _isPlayable(BaseItemDto item) { + final tabContentType = TabContentType.fromItemType(item.type ?? "Audio"); return tabContentType == TabContentType.albums || tabContentType == TabContentType.playlists || tabContentType == TabContentType.artists || tabContentType == TabContentType.songs; } - Future _convertToMediaItem({ - required BaseItemDto item, - required MediaItemParentType parentType, - String? parentId, - }) async { - final tabContentType = TabContentType.fromItemType(item.type!); - final itemId = MediaItemId( - contentType: tabContentType, - parentType: parentType, - parentId: parentId ?? item.parentId, - itemId: item.id, - ); - - final downloadedSong = _downloadService.getSongDownload(item: item); - DownloadItem? downloadedImage; - try { - downloadedImage = _downloadService.getImageDownload(item: item); - } catch (e) { - _androidAutoHelperLogger.warning("Couldn't get the offline image for track '${item.name}' because it's not downloaded or missing a blurhash"); - } - Uri? artUri; - - // replace with content uri or jellyfin api uri - if (downloadedImage != null) { - artUri = downloadedImage.file?.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); - } else if (!FinampSettingsHelper.finampSettings.isOffline) { - artUri = _jellyfinApiHelper.getImageUrl(item: item); - // try to get image file (Android Automotive needs this) - if (artUri != null) { - try { - final fileInfo = await AudioService.cacheManager.getFileFromCache(item.id); - if (fileInfo != null) { - artUri = fileInfo.file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); - } else { - // store the origin in fragment since it should be unused - artUri = artUri.replace(scheme: "content", host: "com.unicornsonlsd.finamp", fragment: artUri.origin); - } - } catch (e) { - _androidAutoHelperLogger.severe("Error setting new media artwork uri for item: ${item.id} name: ${item.name}", e); - } - } - } - - // replace with placeholder art - if (artUri == null) { - final documentsDirectory = await getApplicationDocumentsDirectory(); - artUri = Uri(scheme: "content", host: "com.unicornsonlsd.finamp", path: "${documentsDirectory.absolute.path}/images/album_white.png"); - } - - return MediaItem( - id: itemId.toString(), - playable: _isPlayable(tabContentType), // this dictates whether clicking on an item will try to play it or browse it - album: item.album, - artist: item.artists?.join(", ") ?? item.albumArtist, - artUri: artUri, - title: item.name ?? "unknown", - extras: { - "itemJson": item.toJson(), - "shouldTranscode": FinampSettingsHelper.finampSettings.shouldTranscode, - "downloadedSongPath": downloadedSong?.file?.path, - "isOffline": FinampSettingsHelper.finampSettings.isOffline, - }, - // Jellyfin returns microseconds * 10 for some reason - duration: Duration( - microseconds: - (item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10), - ), - ); - } } diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 65db957bd..e31999ff0 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -26,7 +26,6 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { final _androidAutoHelper = GetIt.instance(); AppLocalizations? _appLocalizations; - bool _localizationsInitialized = false; late final AudioPlayer _player; late final AudioPipeline _audioPipeline; @@ -440,6 +439,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } } + /// Returns the top-level browsable categories for use in a media browser. List _getRootMenu() { return [ MediaItem( @@ -464,17 +464,19 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { )]; } - // menus + /// Implements a media browser, like used in Android Auto. + /// Called with the ID of a non-playable (and therefore browsable) [MediaItem], and returns a list of its children. + /// We jerry-rig the [parentMediaId] to be a JSON string that can be parsed into a [MediaItemId] object, otherwise we don't have a way to tell which item the parentMediaId refers to. + /// There are some special IDs that might be passed to this method: + /// - [AudioService.browsableRootId] is passed when the client requests the root menu (the list of top-level categories) + /// - [AudioService.recentRootId] is passed when the client requests the recent items (e.g. in the "For you" section of Android Auto). @override Future> getChildren(String parentMediaId, [Map? options]) async { // display root category/parent if (parentMediaId == AudioService.browsableRootId) { - if (!_localizationsInitialized) { - _appLocalizations = await AppLocalizations.delegate.load( + _appLocalizations ??= await AppLocalizations.delegate.load( LocaleHelper.locale ?? const Locale("en", "US")); - _localizationsInitialized = true; - } return _getRootMenu(); } @@ -496,7 +498,8 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } - // play specific item + /// Called when a media item is requested to be played. + /// We jerry-rig the [mediaId] to be a JSON string that can be parsed into a [MediaItemId] object, otherwise we don't have a way to tell which item the mediaId refers to. @override Future playFromMediaId(String mediaId, [Map? extras]) async { try { @@ -510,7 +513,8 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } } - // keyboard search + /// Called when a media browser performs a search, e.g. using a search bar or to correct a voice search. + /// Currently, the [extras] parameter isn't passed correctly by AudioService, so some of the metadata available during a voice search isn't available here, that's why we store the [lastSearchQuery] to use it here. @override Future> search(String query, [Map? extras]) async { _audioServiceBackgroundTaskLogger.info("search: $query ; extras: $extras"); @@ -536,7 +540,9 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } - // voice search + /// Called when the user asks for an item to be played based on a query. + /// In this case, the search needs to be performed and the "best" result should be played immediately. + /// [extras] can contain additional information about the search, like the original query, a title, artist, or album (all optional and filled in by e.g. the Voice Assistant for popular items. Provided fields can indicate which type of item was requested). @override Future playFromSearch(String query, [Map? extras]) async { _audioServiceBackgroundTaskLogger.info("playFromSearch: $query ; extras: $extras"); @@ -547,18 +553,27 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { @override Future customAction(String name, [Map? extras]) async { - switch (name) { - case 'shuffle': - return await _androidAutoHelper.toggleShuffle(); + try { + final action = CustomPlaybackActions.values.firstWhere((element) => element.name == name); + switch (action) { + case CustomPlaybackActions.shuffle: + final queueService = GetIt.instance(); + return queueService.togglePlaybackOrder(); + default: + // NOP, handled below + } + } catch (e) { + _audioServiceBackgroundTaskLogger.severe("Custom action '$name' not found.", e); } - + + // only called if no custom action was found return await super.customAction(name, extras); } // triggers when skipping to specific item in android auto queue @override Future skipToQueueItem(int index) async { - skipToIndex(index); + return skipToIndex(index); } void setNextInitialIndex(int index) { @@ -642,6 +657,8 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { controls: [ MediaControl.skipToPrevious, if (_player.playing) MediaControl.pause else MediaControl.play, + // MediaControl.stop.copyWith( + // androidIcon: "drawable/baseline_shuffle_on_24"), MediaControl.stop, MediaControl.skipToNext, MediaControl.custom( @@ -649,7 +666,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { ? "drawable/baseline_shuffle_on_24" : "drawable/baseline_shuffle_24", label: "Shuffle", - name: "shuffle" + name: CustomPlaybackActions.shuffle.name, )], systemActions: const { MediaAction.seek, diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 9c31dbaff..9fb082b97 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; import 'package:finamp/components/global_snackbar.dart'; +import 'package:finamp/gen/assets.gen.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; +import 'package:finamp/services/android_auto_helper.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -24,6 +27,10 @@ import 'music_player_background_task.dart'; /// A track queueing service for Finamp. class QueueService { + + /// Used to build content:// URIs that are handled by Finamp's built-in content provider. + static final contentProviderPackageName = "com.unicornsonlsd.finamp"; + final _jellyfinApiHelper = GetIt.instance(); final _audioHandler = GetIt.instance(); final _finampUserHelper = GetIt.instance(); @@ -482,7 +489,7 @@ class QueueService { jellyfin_models.BaseItemDto item = itemList[i]; try { MediaItem mediaItem = - await _generateMediaItem(item, source.contextNormalizationGain); + await _generateMediaItem(item, contextNormalizationGain: source.contextNormalizationGain); newItems.add(FinampQueueItem( item: mediaItem, source: source, @@ -599,7 +606,7 @@ class QueueService { for (final item in items) { queueItems.add(FinampQueueItem( item: - await _generateMediaItem(item, source?.contextNormalizationGain), + await _generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), source: source ?? _order.originalSource, type: QueueItemQueueType.queue, )); @@ -647,7 +654,7 @@ class QueueService { for (final item in items) { queueItems.add(FinampQueueItem( item: - await _generateMediaItem(item, source?.contextNormalizationGain), + await _generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), source: source ?? QueueItemSource( id: "next-up", @@ -703,7 +710,7 @@ class QueueService { for (final item in items) { queueItems.add(FinampQueueItem( item: - await _generateMediaItem(item, source?.contextNormalizationGain), + await _generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), source: source ?? QueueItemSource( id: "next-up", @@ -967,10 +974,27 @@ class QueueService { /// [contextNormalizationGain] is the normalization gain of the context that the song is being played in, e.g. the album /// Should only be used when the tracks within that context come from the same source, e.g. the same album (or maybe artist?). Usually makes no sense for playlists. - Future _generateMediaItem(jellyfin_models.BaseItemDto item, - double? contextNormalizationGain) async { + Future generateMediaItem( + jellyfin_models.BaseItemDto item, { + double? contextNormalizationGain, + MediaItemParentType? parentType, + String? parentId, + bool Function(jellyfin_models.BaseItemDto item)? isPlayable, + }) async { const uuid = Uuid(); + MediaItemId? itemId; + final tabContentType = TabContentType.fromItemType(item.type ?? "Audio"); + + if (parentType != null) { + itemId = MediaItemId( + contentType: tabContentType, + parentType: parentType, + parentId: parentId ?? item.parentId, + itemId: item.id, + ); + } + final downloadedSong = _isarDownloader.getSongDownload(item: item); DownloadItem? downloadedImage; try { @@ -983,7 +1007,7 @@ class QueueService { // replace with content uri or jellyfin api uri if (downloadedImage != null) { - artUri = downloadedImage.file?.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); + artUri = downloadedImage.file?.uri; } else if (!FinampSettingsHelper.finampSettings.isOffline) { artUri = _jellyfinApiHelper.getImageUrl(item: item); // try to get image file (Android Automotive needs this) @@ -991,10 +1015,7 @@ class QueueService { try { final fileInfo = await AudioService.cacheManager.getFileFromCache(item.id); if (fileInfo != null) { - artUri = fileInfo.file.uri.replace(scheme: "content", host: "com.unicornsonlsd.finamp"); - } else { - // store the origin in fragment since it should be unused - artUri = artUri.replace(scheme: "content", host: "com.unicornsonlsd.finamp", fragment: artUri.origin); + artUri = fileInfo.file.uri; } } catch (e) { _queueServiceLogger.severe("Error setting new media artwork uri for item: ${item.id} name: ${item.name}", e); @@ -1002,14 +1023,20 @@ class QueueService { } } - // replace with placeholder art - if (artUri == null) { - final documentsDirectory = await getApplicationDocumentsDirectory(); - artUri = Uri(scheme: "content", host: "com.unicornsonlsd.finamp", path: "${documentsDirectory.absolute.path}/images/album_white.png"); + if (Platform.isAndroid) { + // replace with placeholder art + if (artUri == null) { + final applicationSupportDirectory = await getApplicationSupportDirectory(); + artUri = Uri(scheme: "content", host: contentProviderPackageName, path: "${applicationSupportDirectory.path}/${Assets.images.albumWhite.path}"); + } else { + // store the origin in fragment since it should be unused + artUri = artUri.replace(scheme: "content", host: contentProviderPackageName, fragment: artUri.origin); + } } return MediaItem( - id: uuid.v4(), + id: itemId?.toString() ?? uuid.v4(), + playable: isPlayable?.call(item) ?? true, // this dictates whether clicking on an item will try to play it or browse it in media browsers like Android Auto album: item.album ?? "unknown", artist: item.artists?.join(", ") ?? item.albumArtist, artUri: artUri, diff --git a/pubspec.lock b/pubspec.lock index bfb5ad6c6..493878bbf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -274,6 +274,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + color: + dependency: transitive + description: + name: color + sha256: ddcdf1b3badd7008233f5acffaf20ca9f5dc2cd0172b75f68f24526a5f5725cb + url: "https://pub.dev" + source: hosted + version: "3.0.0" console: dependency: transitive description: @@ -455,6 +463,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + flutter_gen_core: + dependency: transitive + description: + name: flutter_gen_core + sha256: b9894396b2a790cc2d6eb3ed86e5e113aaed993765b21d4b981c9da4476e0f52 + url: "https://pub.dev" + source: hosted + version: "5.5.0+1" + flutter_gen_runner: + dependency: "direct dev" + description: + name: flutter_gen_runner + sha256: b4c4c54e4dd89022f5e405fe96f16781be2dfbeabe8a70ccdf73b7af1302c655 + url: "https://pub.dev" + source: hosted + version: "5.5.0+1" flutter_launcher_icons: dependency: "direct dev" description: @@ -590,6 +614,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + hashcodes: + dependency: transitive + description: + name: hashcodes + sha256: "80f9410a5b3c8e110c4b7604546034749259f5d6dcca63e0d3c17c9258f1a651" + url: "https://pub.dev" + source: hosted + version: "2.0.0" hive: dependency: "direct main" description: @@ -654,6 +686,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + image_size_getter: + dependency: transitive + description: + name: image_size_getter + sha256: f98c4246144e9b968899d2dfde69091e22a539bb64bc9b0bea51505fbb490e57 + url: "https://pub.dev" + source: hosted + version: "2.1.3" infinite_scroll_pagination: dependency: "direct main" description: @@ -945,6 +985,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + url: "https://pub.dev" + source: hosted + version: "1.0.1" path_provider: dependency: "direct main" description: @@ -1503,6 +1551,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 191dcdfd9..92cf15f9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -106,6 +106,7 @@ dev_dependencies: flutter_test: sdk: flutter build_runner: ^2.3.3 + flutter_gen_runner: chopper_generator: ^8.0.0 hive_generator: ^2.0.0 json_serializable: ^6.7.1 From 6d12f78b891fe110b290a789f187a803068560ff Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Mon, 27 May 2024 22:37:43 +0200 Subject: [PATCH 38/42] fix errors, properly join file paths --- lib/main.dart | 3 ++- lib/services/android_auto_helper.dart | 16 +++++++--------- lib/services/queue_service.dart | 17 +++++++++-------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 87867bd0e..e88740136 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,6 +35,7 @@ import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:path/path.dart' as path_helper; import 'components/LogsScreen/copy_logs_button.dart'; import 'components/LogsScreen/share_logs_button.dart'; @@ -270,7 +271,7 @@ Future _setupOSIntegration() async { // Load the album image from assets and save it to the documents directory for use in Android Auto final applicationSupportDirectory = await getApplicationSupportDirectory(); - final albumImageFile = File('${applicationSupportDirectory.path}/${Assets.images.albumWhite.path}'); + final albumImageFile = File(path_helper.join(applicationSupportDirectory.path, Assets.images.albumWhite.path)); if (!(await albumImageFile.exists())) { final albumImageBytes = await rootBundle.load(Assets.images.albumWhite.path); final albumBuffer = albumImageBytes.buffer; diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 6880c6c88..0ad48c5ab 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -1,17 +1,12 @@ -import 'dart:math'; - import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; import 'package:finamp/components/MusicScreen/music_screen_tab_view.dart'; -import 'package:finamp/components/global_snackbar.dart'; import 'package:finamp/services/downloads_service.dart'; import 'package:get_it/get_it.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; import 'finamp_user_helper.dart'; import 'jellyfin_api_helper.dart'; import 'finamp_settings_helper.dart'; @@ -116,7 +111,7 @@ class AndroidAutoHelper { } // only sort items if we are not playing them - return _isPlayable(itemId.contentType) ? downloadedItems : sortItems(downloadedItems, sortBy, sortOrder); + return _isPlayable(contentType: itemId.contentType) ? downloadedItems : sortItems(downloadedItems, sortBy, sortOrder); } } @@ -505,7 +500,7 @@ class AndroidAutoHelper { final queueService = GetIt.instance(); // shouldn't happen, but just in case - if (!_isPlayable(itemId.contentType)) { + if (!_isPlayable(contentType: itemId.contentType)) { _androidAutoHelperLogger.warning("Tried to play from media id with non-playable item type ${itemId.parentType.name}"); return; } @@ -645,8 +640,11 @@ class AndroidAutoHelper { // albums, playlists, and songs should play when clicked // clicking artists starts an instant mix, so they are technically playable // genres has subcategories, so it should be browsable but not playable - bool _isPlayable(BaseItemDto item) { - final tabContentType = TabContentType.fromItemType(item.type ?? "Audio"); + bool _isPlayable({ + BaseItemDto? item, + TabContentType? contentType, + }) { + final tabContentType = TabContentType.fromItemType(item?.type ?? contentType?.itemType.idString ?? "Audio"); return tabContentType == TabContentType.albums || tabContentType == TabContentType.playlists || tabContentType == TabContentType.artists || tabContentType == TabContentType.songs; } diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 9fb082b97..1a114064b 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -8,7 +8,6 @@ import 'package:finamp/components/global_snackbar.dart'; import 'package:finamp/gen/assets.gen.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; -import 'package:finamp/services/android_auto_helper.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -17,6 +16,7 @@ import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; +import 'package:path/path.dart' as path_helper; import '../components/PlayerScreen/queue_source_helper.dart'; import 'downloads_service.dart'; @@ -489,7 +489,7 @@ class QueueService { jellyfin_models.BaseItemDto item = itemList[i]; try { MediaItem mediaItem = - await _generateMediaItem(item, contextNormalizationGain: source.contextNormalizationGain); + await generateMediaItem(item, contextNormalizationGain: source.contextNormalizationGain); newItems.add(FinampQueueItem( item: mediaItem, source: source, @@ -606,7 +606,7 @@ class QueueService { for (final item in items) { queueItems.add(FinampQueueItem( item: - await _generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), + await generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), source: source ?? _order.originalSource, type: QueueItemQueueType.queue, )); @@ -654,7 +654,7 @@ class QueueService { for (final item in items) { queueItems.add(FinampQueueItem( item: - await _generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), + await generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), source: source ?? QueueItemSource( id: "next-up", @@ -710,7 +710,7 @@ class QueueService { for (final item in items) { queueItems.add(FinampQueueItem( item: - await _generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), + await generateMediaItem(item, contextNormalizationGain: source?.contextNormalizationGain), source: source ?? QueueItemSource( id: "next-up", @@ -979,7 +979,7 @@ class QueueService { double? contextNormalizationGain, MediaItemParentType? parentType, String? parentId, - bool Function(jellyfin_models.BaseItemDto item)? isPlayable, + bool Function({ jellyfin_models.BaseItemDto? item, TabContentType? contentType })? isPlayable, }) async { const uuid = Uuid(); @@ -1023,11 +1023,12 @@ class QueueService { } } + // use content provider for handling media art on Android if (Platform.isAndroid) { // replace with placeholder art if (artUri == null) { final applicationSupportDirectory = await getApplicationSupportDirectory(); - artUri = Uri(scheme: "content", host: contentProviderPackageName, path: "${applicationSupportDirectory.path}/${Assets.images.albumWhite.path}"); + artUri = Uri(scheme: "content", host: contentProviderPackageName, path: path_helper.join(applicationSupportDirectory.path, Assets.images.albumWhite.path)); } else { // store the origin in fragment since it should be unused artUri = artUri.replace(scheme: "content", host: contentProviderPackageName, fragment: artUri.origin); @@ -1036,7 +1037,7 @@ class QueueService { return MediaItem( id: itemId?.toString() ?? uuid.v4(), - playable: isPlayable?.call(item) ?? true, // this dictates whether clicking on an item will try to play it or browse it in media browsers like Android Auto + playable: isPlayable?.call(item: item) ?? true, // this dictates whether clicking on an item will try to play it or browse it in media browsers like Android Auto album: item.album ?? "unknown", artist: item.artists?.join(", ") ?? item.albumArtist, artUri: artUri, From 03470f9dbe0d034e91fe25ce28dececeda3c633a Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Mon, 27 May 2024 22:59:09 +0200 Subject: [PATCH 39/42] fix issues with content provider custom artwork URIs --- lib/main.dart | 2 +- lib/services/android_auto_helper.dart | 2 +- lib/services/queue_service.dart | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index e88740136..7600b3385 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -271,7 +271,7 @@ Future _setupOSIntegration() async { // Load the album image from assets and save it to the documents directory for use in Android Auto final applicationSupportDirectory = await getApplicationSupportDirectory(); - final albumImageFile = File(path_helper.join(applicationSupportDirectory.path, Assets.images.albumWhite.path)); + final albumImageFile = File(path_helper.join(applicationSupportDirectory.absolute.path, Assets.images.albumWhite.path)); if (!(await albumImageFile.exists())) { final albumImageBytes = await rootBundle.load(Assets.images.albumWhite.path); final albumBuffer = albumImageBytes.buffer; diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index 0ad48c5ab..e7cac3ef1 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -484,7 +484,7 @@ class AndroidAutoHelper { try { await audioServiceHelper.shuffleAll(FinampSettingsHelper.finampSettings.onlyShowFavourite); } catch (err) { - _androidAutoHelperLogger.severe("Error while shuffling all songs: $err"); + _androidAutoHelperLogger.severe("Error while shuffling all songs", err); } } diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 1a114064b..1e9f00137 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -1028,10 +1028,10 @@ class QueueService { // replace with placeholder art if (artUri == null) { final applicationSupportDirectory = await getApplicationSupportDirectory(); - artUri = Uri(scheme: "content", host: contentProviderPackageName, path: path_helper.join(applicationSupportDirectory.path, Assets.images.albumWhite.path)); + artUri = Uri(scheme: "content", host: contentProviderPackageName, path: path_helper.join(applicationSupportDirectory.absolute.path, Assets.images.albumWhite.path)); } else { // store the origin in fragment since it should be unused - artUri = artUri.replace(scheme: "content", host: contentProviderPackageName, fragment: artUri.origin); + artUri = artUri.replace(scheme: "content", host: contentProviderPackageName, fragment: ["http", "https"].contains(artUri.scheme) ? artUri.origin : null); } } From ddc66e8479ab5785306b332f1e394c8fe616e6b8 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Mon, 27 May 2024 23:00:48 +0200 Subject: [PATCH 40/42] fix album name always being shown --- lib/services/queue_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 1e9f00137..2f33c4485 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -1038,7 +1038,7 @@ class QueueService { return MediaItem( id: itemId?.toString() ?? uuid.v4(), playable: isPlayable?.call(item: item) ?? true, // this dictates whether clicking on an item will try to play it or browse it in media browsers like Android Auto - album: item.album ?? "unknown", + album: item.album, artist: item.artists?.join(", ") ?? item.albumArtist, artUri: artUri, title: item.name ?? "unknown", From 42740898fc3263892a57c17ca89f24a990dc8e29 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Thu, 30 May 2024 20:22:00 +0200 Subject: [PATCH 41/42] search multiple item types --- .../AlbumScreen/song_list_tile.dart | 4 +- .../DownloadsScreen/downloads_overview.dart | 2 +- lib/services/android_auto_helper.dart | 502 ++++++++++++++---- lib/services/jellyfin_api.chopper.dart | 38 ++ lib/services/jellyfin_api.dart | 60 +++ lib/services/jellyfin_api_helper.dart | 51 ++ .../music_player_background_task.dart | 3 +- lib/services/queue_service.dart | 26 +- 8 files changed, 571 insertions(+), 115 deletions(-) diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index 9b40b5163..67b101431 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -274,13 +274,13 @@ class _SongListTileState extends ConsumerState // TODO put in a real offline songs implementation if (FinampSettingsHelper.finampSettings.isOffline) { final settings = FinampSettingsHelper.finampSettings; - final downloadService = GetIt.instance(); + final downloadsService = GetIt.instance(); final finampUserHelper = GetIt.instance(); // get all downloaded songs in order List offlineItems; // If we're on the songs tab, just get all of the downloaded items - offlineItems = await downloadService.getAllSongs( + offlineItems = await downloadsService.getAllSongs( // nameFilter: widget.searchTerm, viewFilter: finampUserHelper.currentUser?.currentView?.id, nullableViewFilters: diff --git a/lib/components/DownloadsScreen/downloads_overview.dart b/lib/components/DownloadsScreen/downloads_overview.dart index bf625d9a3..16e100d7a 100644 --- a/lib/components/DownloadsScreen/downloads_overview.dart +++ b/lib/components/DownloadsScreen/downloads_overview.dart @@ -33,7 +33,7 @@ class DownloadsOverview extends StatelessWidget { stream: downloadsService.downloadCountsStream, initialData: downloadsService.downloadCounts, builder: (context, countSnapshot) { - // This is throttled to 10 per second in downloadService constructor. + // This is throttled to 10 per second in downloadsService constructor. return StreamBuilder>( stream: downloadsService.downloadStatusesStream, initialData: downloadsService.downloadStatuses, diff --git a/lib/services/android_auto_helper.dart b/lib/services/android_auto_helper.dart index e7cac3ef1..d801b15c5 100644 --- a/lib/services/android_auto_helper.dart +++ b/lib/services/android_auto_helper.dart @@ -1,8 +1,10 @@ import 'package:audio_service/audio_service.dart'; import 'package:collection/collection.dart'; import 'package:finamp/components/MusicScreen/music_screen_tab_view.dart'; +import 'package:finamp/components/global_snackbar.dart'; import 'package:finamp/services/downloads_service.dart'; import 'package:get_it/get_it.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:finamp/models/finamp_models.dart'; @@ -27,7 +29,7 @@ class AndroidAutoHelper { final _finampUserHelper = GetIt.instance(); final _jellyfinApiHelper = GetIt.instance(); - final _downloadService = GetIt.instance(); + final _downloadsService = GetIt.instance(); // actively remembered search query because Android Auto doesn't give us the extras during a regular search (e.g. clicking the "Search Results" button on the player screen after a voice search) AndroidAutoSearchQuery? _lastSearchQuery; @@ -39,7 +41,7 @@ class AndroidAutoHelper { AndroidAutoSearchQuery? get lastSearchQuery => _lastSearchQuery; Future getParentFromId(String parentId) async { - final downloadedParent = await _downloadService.getCollectionInfo(id: parentId); + final downloadedParent = await _downloadsService.getCollectionInfo(id: parentId); if (downloadedParent != null) { return downloadedParent.baseItem; } else if (FinampSettingsHelper.finampSettings.isOffline) { @@ -61,7 +63,7 @@ class AndroidAutoHelper { // if we are in offline mode and in root parent/collection, display all matching downloaded parents if (FinampSettingsHelper.finampSettings.isOffline && itemId.parentType == MediaItemParentType.rootCollection) { List baseItems = []; - for (final downloadedParent in await _downloadService.getAllCollections()) { + for (final downloadedParent in await _downloadsService.getAllCollections()) { if (baseItems.length >= offlineModeLimit) break; if (downloadedParent.baseItem != null && downloadedParent.baseItemType == itemId.contentType.itemType) { baseItems.add(downloadedParent.baseItem!); @@ -78,7 +80,7 @@ class AndroidAutoHelper { if (itemId.contentType == TabContentType.genres) { final genreBaseItem = await getParentFromId(itemId.itemId!); - final List genreAlbums = (await _downloadService.getAllCollections( + final List genreAlbums = (await _downloadsService.getAllCollections( baseTypeFilter: BaseItemDtoType.album, relatedTo: genreBaseItem)).toList() .map((e) => e.baseItem).whereNotNull().toList(); @@ -89,7 +91,7 @@ class AndroidAutoHelper { final artistBaseItem = await getParentFromId(itemId.itemId!); - final List artistAlbums = (await _downloadService.getAllCollections( + final List artistAlbums = (await _downloadsService.getAllCollections( baseTypeFilter: BaseItemDtoType.album, relatedTo: artistBaseItem)).toList() .map((e) => e.baseItem).whereNotNull().toList(); @@ -98,14 +100,14 @@ class AndroidAutoHelper { final List allSongs = []; for (var album in artistAlbums) { - allSongs.addAll(await _downloadService + allSongs.addAll(await _downloadsService .getCollectionSongs(album, playable: true)); } return allSongs; } else { - var downloadedParent = await _downloadService.getCollectionInfo(id: itemId.itemId); + var downloadedParent = await _downloadsService.getCollectionInfo(id: itemId.itemId); if (downloadedParent != null && downloadedParent.baseItem != null) { - final downloadedItems = await _downloadService.getCollectionSongs(downloadedParent.baseItem!); + final downloadedItems = await _downloadsService.getCollectionSongs(downloadedParent.baseItem!); if (downloadedItems.length >= offlineModeLimit) { downloadedItems.removeRange(offlineModeLimit, downloadedItems.length - 1); } @@ -154,7 +156,7 @@ class AndroidAutoHelper { // otherwise, use the current view as fallback to ensure we get the correct items. final parentItem = itemId.parentType == MediaItemParentType.collection ? BaseItemDto(id: itemId.itemId!, type: itemId.contentType.itemType.idString) - : _finampUserHelper.currentUser?.currentView; + : (itemId.contentType == TabContentType.playlists ? null : _finampUserHelper.currentUser?.currentView); final items = await _jellyfinApiHelper.getItems(parentItem: parentItem, sortBy: sortBy.jellyfinName(itemId.contentType), sortOrder: sortOrder.toString(), includeItemTypes: includeItemTypes, limit: onlineModeLimit); return items ?? []; @@ -183,82 +185,49 @@ class AndroidAutoHelper { try { - List? searchResult; - - if (searchQuery.extras != null && searchQuery.extras?["android.intent.extra.title"] != null) { - // search for exact query first, then search for adjusted query - // sometimes Google's adjustment might not be what we want, but sometimes it actually helps - List? searchResultExactQuery; - List? searchResultAdjustedQuery; - try { - searchResultExactQuery = await _getResults( - searchTerm: searchQuery.query.trim(), - itemType: TabContentType.songs.itemType, - limit: 7, - ); - } catch (e) { - _androidAutoHelperLogger.severe("Error while searching for exact query:", e); - } - try { - searchResultExactQuery = await _getResults( - searchTerm: searchQuery.extras!["android.intent.extra.title"].trim(), - itemType: TabContentType.songs.itemType, - limit: (searchResultExactQuery != null && searchResultExactQuery.isNotEmpty) ? 13 : 20, - ); - } catch (e) { - _androidAutoHelperLogger.severe("Error while searching for adjusted query:", e); - } - - searchResult = searchResultExactQuery?.followedBy(searchResultAdjustedQuery ?? []).toList() ?? []; - - } else { - searchResult = await _getResults( - searchTerm: searchQuery.query.trim(), - itemType: TabContentType.songs.itemType, - limit: 20, - ); - } + final searchFuture = Future.wait([ + _searchPlaylists(searchQuery, limit: 3), + _searchTracks(searchQuery, limit: 5), + _searchAlbums(searchQuery, limit: 5), + _searchArtists(searchQuery, limit: 3), + ]); - final List filteredSearchResults = []; - // filter out duplicates - for (final item in searchResult ?? []) { - if (!filteredSearchResults.any((element) => element.id == item.id)) { - filteredSearchResults.add(item); - } - } - - if (searchResult != null && searchResult.isEmpty) { - _androidAutoHelperLogger.warning("No search results found for query: ${searchQuery.query} (extras: ${searchQuery.extras})"); - } + final [playlistResults, trackResults, albumResults, artistResults] = await searchFuture; - int calculateMatchQuality(BaseItemDto item, AndroidAutoSearchQuery searchQuery) { - final title = item.name ?? ""; - - final wantedTitle = searchQuery.extras?["android.intent.extra.title"]; - final wantedArtist = searchQuery.extras?["android.intent.extra.artist"]; - - if ( - wantedArtist != null && - (item.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (searchQuery.extras?["android.intent.extra.artist"]?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false) - ) { - return 1; - } else if (title == wantedTitle) { - // Title matches, normal priority - return 0; - } else { - // No exact match, lower priority - return -1; + final List allSearchResults = playlistResults + .followedBy(trackResults) + .followedBy(albumResults) + .followedBy(artistResults) + .toList(); + + final List mediaItems = []; + + for (final item in allSearchResults) { + final mediaItem = await queueService.generateMediaItem(item, parentType: MediaItemParentType.collection, parentId: item.parentId, isPlayable: _isPlayable); + + // assign a group hint based on the item type, so Android Auto can group search results by type + switch(item.type) { + case "Audio": + mediaItem.extras?["android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"] = GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.songs : "Tracks"; + break; + case "MusicAlbum": + mediaItem.extras?["android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"] = GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.albums : "Albums"; + break; + case "MusicArtist": + mediaItem.extras?["android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"] = GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.artists : "Artists"; + break; + case "MusicGenre": + mediaItem.extras?["android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"] = GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.genres : "Genres"; + break; + case "Playlist": + mediaItem.extras?["android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT"] = GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.playlists : "Playlists"; + break; + default: + break; } + mediaItems.add(mediaItem); } - - // sort items based on match quality with extras - filteredSearchResults.sort((a, b) { - final aMatchQuality = calculateMatchQuality(a, searchQuery); - final bMatchQuality = calculateMatchQuality(b, searchQuery); - return bMatchQuality.compareTo(aMatchQuality); - }); - - return [ for (final item in filteredSearchResults) await queueService.generateMediaItem(item, parentType: MediaItemParentType.instantMix, parentId: item.parentId, isPlayable: _isPlayable) ]; + return mediaItems; } catch (err) { _androidAutoHelperLogger.severe("Error while searching:", err); return []; @@ -292,7 +261,7 @@ class AndroidAutoHelper { itemType = TabContentType.artists.itemType; alternativeQuery = searchQuery.extras?["android.intent.extra.artist"]; } else { - // if no metadata is provided, search for song *and* playlists, preferring playlists + // if no metadata is provided, search for tracks *and* playlists, preferring playlists searchForPlaylists = true; } @@ -305,7 +274,7 @@ class AndroidAutoHelper { List? searchResult; if (FinampSettingsHelper.finampSettings.isOffline) { - List? offlineItems = await _downloadService.getAllCollections( + List? offlineItems = await _downloadsService.getAllCollections( nameFilter: searchTerm, baseTypeFilter: TabContentType.playlists.itemType, fullyDownloaded: false, @@ -332,7 +301,7 @@ class AndroidAutoHelper { List? items; if (FinampSettingsHelper.finampSettings.isOffline) { - items = await _downloadService.getCollectionSongs(playlist, playable: true); + items = await _downloadsService.getCollectionSongs(playlist, playable: true); } else { items = await _jellyfinApiHelper.getItems(parentItem: playlist, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); } @@ -363,7 +332,7 @@ class AndroidAutoHelper { List? searchResult = await _getResults( searchTerm: searchTerm, - itemType: itemType, + itemTypes: [itemType], ); if (searchResult == null || searchResult.isEmpty) { @@ -373,7 +342,7 @@ class AndroidAutoHelper { searchResult = await _getResults( searchTerm: alternativeQuery.trim(), - itemType: itemType, + itemTypes: [itemType], ); } @@ -401,7 +370,7 @@ class AndroidAutoHelper { List? items; if (FinampSettingsHelper.finampSettings.isOffline) { - items = await _downloadService.getCollectionSongs(album, playable: true); + items = await _downloadsService.getCollectionSongs(album, playable: true); } else { items = await _jellyfinApiHelper.getItems(parentItem: album, includeItemTypes: TabContentType.songs.itemType.idString, sortBy: "ParentIndexNumber,IndexNumber,SortName", sortOrder: "Ascending", limit: 200); } @@ -440,7 +409,7 @@ class AndroidAutoHelper { if (FinampSettingsHelper.finampSettings.isOffline) { List offlineItems; // If we're on the songs tab, just get all of the downloaded items - offlineItems = await _downloadService.getAllSongs( + offlineItems = await _downloadsService.getAllSongs( // nameFilter: widget.searchTerm, viewFilter: finampUserHelper.currentUser?.currentView?.id, nullableViewFilters: @@ -490,7 +459,14 @@ class AndroidAutoHelper { Future> getMediaItems(MediaItemId itemId) async { final queueService = GetIt.instance(); - return [ for (final item in await getBaseItems(itemId)) await queueService.generateMediaItem(item, parentType: MediaItemParentType.collection, parentId: item.parentId, isPlayable: _isPlayable) ]; + final items = await getBaseItems(itemId); + final List mediaItems = []; + + for (final item in items) { + final mediaItem = await queueService.generateMediaItem(item, parentType: MediaItemParentType.collection, parentId: item.parentId, isPlayable: _isPlayable); + mediaItems.add(mediaItem); + } + return mediaItems; } Future playFromMediaId(MediaItemId itemId) async { @@ -509,7 +485,7 @@ class AndroidAutoHelper { if (FinampSettingsHelper.finampSettings.isOffline) { List offlineItems; // If we're on the songs tab, just get all of the downloaded items - offlineItems = await _downloadService.getAllSongs( + offlineItems = await _downloadsService.getAllSongs( // nameFilter: widget.searchTerm, viewFilter: finampUserHelper.currentUser?.currentView?.id, nullableViewFilters: @@ -585,9 +561,314 @@ class AndroidAutoHelper { )); } + Future> _searchTracks(AndroidAutoSearchQuery searchQuery, { + int limit = 20, + }) async { + List? searchResult; + + // search for exact query first, then search for adjusted query + // sometimes Google's adjustment might not be what we want, but sometimes it actually helps + List? searchResultExactQuery; + List? searchResultAdjustedQuery; + try { + searchResultExactQuery = await _getResults( + searchTerm: searchQuery.query.trim(), + itemTypes: [TabContentType.songs.itemType], + limit: searchQuery.extras?["android.intent.extra.title"] != null ? (limit/2).round() : limit, + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for exact query:", e); + } + if (searchQuery.extras?["android.intent.extra.title"] != null) { + try { + searchResultAdjustedQuery = await _getResults( + searchTerm: searchQuery.extras!["android.intent.extra.title"].trim(), + itemTypes: [TabContentType.songs.itemType], + limit: limit - (searchResultExactQuery?.length ?? 0), + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for adjusted query:", e); + } + + } + + searchResult = searchResultExactQuery?.followedBy(searchResultAdjustedQuery ?? []).toList() ?? []; + + final List filteredSearchResults = []; + // filter out duplicates + for (final item in searchResult) { + if (!filteredSearchResults.any((element) => element.id == item.id)) { + filteredSearchResults.add(item); + } + } + + if (searchResult.isEmpty) { + _androidAutoHelperLogger.warning("No search results found for query: ${searchQuery.query} (extras: ${searchQuery.extras})"); + } + + int calculateMatchQuality(BaseItemDto item, AndroidAutoSearchQuery searchQuery) { + final title = item.name ?? ""; + + final wantedTitle = searchQuery.extras?["android.intent.extra.title"]?.toString().trim(); + final wantedArtist = searchQuery.extras?["android.intent.extra.artist"]?.toString().trim(); + + if ( + title.toLowerCase() == searchQuery.query.toLowerCase() || + wantedArtist != null && + (item.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (wantedArtist?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false) + ) { + // Title matches exactly or artist matches, highest priority + return 1; + } else if (title == wantedTitle) { + // Title matches, normal priority + return 0; + } else { + // No exact match, lower priority + return -1; + } + } + + // sort items based on match quality with extras + filteredSearchResults.sort((a, b) { + final aMatchQuality = calculateMatchQuality(a, searchQuery); + final bMatchQuality = calculateMatchQuality(b, searchQuery); + return bMatchQuality.compareTo(aMatchQuality); + }); + + return filteredSearchResults; + } + + Future> _searchAlbums(AndroidAutoSearchQuery searchQuery, { + int limit = 20, + }) async { + List? searchResult; + + bool hasAlbumMetadata = searchQuery.extras?["android.intent.extra.album"] != null; + + // search for exact query first, then search for adjusted query + // sometimes Google's adjustment might not be what we want, but sometimes it actually helps + List? searchResultExactQuery; + List? searchResultAdjustedQuery; + try { + searchResultExactQuery = await _getResults( + searchTerm: searchQuery.query.trim(), + itemTypes: [TabContentType.albums.itemType], + limit: hasAlbumMetadata ? (limit/2).round() : limit, + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for exact query:", e); + } + if (hasAlbumMetadata) { + try { + searchResultAdjustedQuery = await _getResults( + searchTerm: searchQuery.extras!["android.intent.extra.album"].trim(), + itemTypes: [TabContentType.albums.itemType], + limit: limit - (searchResultExactQuery?.length ?? 0), + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for adjusted query:", e); + } + + } + + searchResult = searchResultExactQuery?.followedBy(searchResultAdjustedQuery ?? []).toList() ?? []; + + final List filteredSearchResults = []; + // filter out duplicates + for (final item in searchResult) { + if (!filteredSearchResults.any((element) => element.id == item.id)) { + filteredSearchResults.add(item); + } + } + + if (searchResult.isEmpty) { + _androidAutoHelperLogger.warning("No search results found for query: ${searchQuery.query} (extras: ${searchQuery.extras})"); + } + + int calculateMatchQuality(BaseItemDto item, AndroidAutoSearchQuery searchQuery) { + final title = item.name ?? ""; + + final wantedAlbum = searchQuery.extras?["android.intent.extra.album"]?.toString().trim(); + final wantedArtist = searchQuery.extras?["android.intent.extra.artist"]?.toString().trim(); + + if ( + title.toLowerCase() == searchQuery.query.toLowerCase() || + wantedArtist != null && + (item.albumArtists?.any((artist) => (artist.name?.isNotEmpty ?? false) && (wantedArtist?.toString().toLowerCase().contains(artist.name?.toLowerCase() ?? "") ?? false)) ?? false) + ) { + // Title matches exactly or artist matches, highest priority + return 1; + } else if (title == wantedAlbum) { + // Title matches, normal priority + return 0; + } else { + // No exact match, lower priority + return -1; + } + } + + // sort items based on match quality with extras + filteredSearchResults.sort((a, b) { + final aMatchQuality = calculateMatchQuality(a, searchQuery); + final bMatchQuality = calculateMatchQuality(b, searchQuery); + return bMatchQuality.compareTo(aMatchQuality); + }); + + return filteredSearchResults; + } + + Future> _searchPlaylists(AndroidAutoSearchQuery searchQuery, { + int limit = 20, + }) async { + List? searchResult; + + bool hasPlaylistMetadata = searchQuery.extras?["android.intent.extra.playlist"] != null; + + // search for exact query first, then search for adjusted query + // sometimes Google's adjustment might not be what we want, but sometimes it actually helps + List? searchResultExactQuery; + List? searchResultAdjustedQuery; + try { + searchResultExactQuery = await _getResults( + searchTerm: searchQuery.query.trim(), + itemTypes: [TabContentType.playlists.itemType], + limit: hasPlaylistMetadata ? (limit/2).round() : limit, + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for exact query:", e); + } + if (hasPlaylistMetadata) { + try { + searchResultAdjustedQuery = await _getResults( + searchTerm: searchQuery.extras!["android.intent.extra.playlist"].trim(), + itemTypes: [TabContentType.playlists.itemType], + limit: limit - (searchResultExactQuery?.length ?? 0), + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for adjusted query:", e); + } + + } + + searchResult = searchResultExactQuery?.followedBy(searchResultAdjustedQuery ?? []).toList() ?? []; + + final List filteredSearchResults = []; + // filter out duplicates + for (final item in searchResult) { + if (!filteredSearchResults.any((element) => element.id == item.id)) { + filteredSearchResults.add(item); + } + } + + if (searchResult.isEmpty) { + _androidAutoHelperLogger.warning("No search results found for query: ${searchQuery.query} (extras: ${searchQuery.extras})"); + } + + int calculateMatchQuality(BaseItemDto item, AndroidAutoSearchQuery searchQuery) { + final title = item.name ?? ""; + + final wantedPlaylist = searchQuery.extras?["android.intent.extra.playlist"]?.toString().trim(); + + if (title.toLowerCase() == searchQuery.query.toLowerCase()) { + // Title matches exactly, highest priority + return 1; + } else if (title == wantedPlaylist) { + // Title matches metadata, normal priority + return 0; + } else { + // No exact match, lower priority + return -1; + } + } + + // sort items based on match quality with extras + filteredSearchResults.sort((a, b) { + final aMatchQuality = calculateMatchQuality(a, searchQuery); + final bMatchQuality = calculateMatchQuality(b, searchQuery); + return bMatchQuality.compareTo(aMatchQuality); + }); + + return filteredSearchResults; + } + + Future> _searchArtists(AndroidAutoSearchQuery searchQuery, { + int limit = 20, + }) async { + List? searchResult; + + bool hasArtistMetadata = searchQuery.extras?["android.intent.extra.artist"] != null; + + // search for exact query first, then search for adjusted query + // sometimes Google's adjustment might not be what we want, but sometimes it actually helps + List? searchResultExactQuery; + List? searchResultAdjustedQuery; + try { + searchResultExactQuery = await _getResults( + searchTerm: searchQuery.query.trim(), + itemTypes: [TabContentType.artists.itemType], + limit: hasArtistMetadata ? (limit/2).round() : limit, + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for exact query:", e); + } + if (hasArtistMetadata) { + try { + searchResultAdjustedQuery = await _getResults( + searchTerm: searchQuery.extras!["android.intent.extra.artist"].trim(), + itemTypes: [TabContentType.artists.itemType], + limit: limit - (searchResultExactQuery?.length ?? 0), + ); + } catch (e) { + _androidAutoHelperLogger.severe("Error while searching for adjusted query:", e); + } + + } + + searchResult = searchResultExactQuery?.followedBy(searchResultAdjustedQuery ?? []).toList() ?? []; + + final List filteredSearchResults = []; + // filter out duplicates + for (final item in searchResult) { + if (!filteredSearchResults.any((element) => element.id == item.id)) { + filteredSearchResults.add(item); + } + } + + if (searchResult.isEmpty) { + _androidAutoHelperLogger.warning("No search results found for query: ${searchQuery.query} (extras: ${searchQuery.extras})"); + } + + int calculateMatchQuality(BaseItemDto item, AndroidAutoSearchQuery searchQuery) { + final title = item.name ?? ""; + + final wantedArtist = searchQuery.extras?["android.intent.extra.artist"]?.toString().trim(); + + if (title.toLowerCase() == searchQuery.query.toLowerCase()) { + // Title matches exactly, highest priority + return 1; + } else + if (title == wantedArtist) { + // Title matches, normal priority + return 0; + } else { + // No exact match, lower priority + return -1; + } + } + + // sort items based on match quality with extras + filteredSearchResults.sort((a, b) { + final aMatchQuality = calculateMatchQuality(a, searchQuery); + final bMatchQuality = calculateMatchQuality(b, searchQuery); + return bMatchQuality.compareTo(aMatchQuality); + }); + + return filteredSearchResults; + } + Future?> _getResults({ required String searchTerm, - required BaseItemDtoType itemType, + required List itemTypes, int limit = 25, }) async { final jellyfinApiHelper = GetIt.instance(); @@ -598,40 +879,49 @@ class AndroidAutoHelper { List offlineItems; - if (itemType == TabContentType.songs.itemType) { + if (itemTypes.first == TabContentType.songs.itemType) { // If we're on the songs tab, just get all of the downloaded items // We should probably try to page this, at least if we are sorting by name - offlineItems = await _downloadService.getAllSongs( + offlineItems = await _downloadsService.getAllSongs( nameFilter: searchTerm, viewFilter: finampUserHelper.currentUser?.currentView?.id, nullableViewFilters: FinampSettingsHelper.finampSettings.showDownloadsWithUnknownLibrary, onlyFavorites: false); } else { - offlineItems = await _downloadService.getAllCollections( + offlineItems = await _downloadsService.getAllCollections( nameFilter: searchTerm, - baseTypeFilter: itemType, + baseTypeFilter: itemTypes.first, fullyDownloaded: false, - viewFilter: itemType == TabContentType.albums.itemType + viewFilter: itemTypes.first == TabContentType.albums.itemType ? finampUserHelper.currentUser?.currentView?.id : null, - childViewFilter: (itemType != TabContentType.albums.itemType && - itemType != TabContentType.playlists.itemType) + childViewFilter: (itemTypes.first != TabContentType.albums.itemType && + itemTypes.first != TabContentType.playlists.itemType) ? finampUserHelper.currentUser?.currentView?.id : null, - nullableViewFilters: itemType == TabContentType.albums.itemType && + nullableViewFilters: itemTypes.first == TabContentType.albums.itemType && FinampSettingsHelper.finampSettings.showDownloadsWithUnknownLibrary, onlyFavorites: false); } searchResult = offlineItems.map((e) => e.baseItem).whereNotNull().toList(); } else { - searchResult = await jellyfinApiHelper.getItems( - parentItem: finampUserHelper.currentUser?.currentView, - includeItemTypes: itemType.idString, - searchTerm: searchTerm, - startIndex: 0, - limit: limit, // get more than the first result so we can filter using additional metadata - ); + if (itemTypes.first == BaseItemDtoType.artist) { + searchResult = await jellyfinApiHelper.getArtists( + parentItem: finampUserHelper.currentUser?.currentView, + searchTerm: searchTerm, + startIndex: 0, + limit: limit, + ); + } else { + searchResult = await jellyfinApiHelper.getItems( + parentItem: itemTypes.contains(BaseItemDtoType.playlist) ? null : finampUserHelper.currentUser?.currentView, + includeItemTypes: itemTypes.map((type) => type.idString).join(","), + searchTerm: searchTerm, + startIndex: 0, + limit: limit, // get more than the first result so we can filter using additional metadata + ); + } } return searchResult; diff --git a/lib/services/jellyfin_api.chopper.dart b/lib/services/jellyfin_api.chopper.dart index ce77a6ea1..636081afa 100644 --- a/lib/services/jellyfin_api.chopper.dart +++ b/lib/services/jellyfin_api.chopper.dart @@ -490,6 +490,44 @@ final class _$JellyfinApi extends JellyfinApi { ); } + @override + Future getArtists({ + String? parentId, + String? sortBy, + String? sortOrder, + String? fields = defaultFields, + String? searchTerm, + String? filters, + int? startIndex, + int? limit, + bool? isFavorite, + }) async { + final Uri $url = Uri.parse('/Artists'); + final Map $params = { + 'ParentId': parentId, + 'SortBy': sortBy, + 'SortOrder': sortOrder, + 'Fields': fields, + 'SearchTerm': searchTerm, + 'Filters': filters, + 'StartIndex': startIndex, + 'Limit': limit, + 'IsFavorite': isFavorite, + }; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + @override Future getAlbumArtists({ String? includeItemTypes, diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart index 6b4a43e42..93240f9dd 100644 --- a/lib/services/jellyfin_api.dart +++ b/lib/services/jellyfin_api.dart @@ -325,6 +325,66 @@ abstract class JellyfinApi extends ChopperService { @Query() String? entryIds, }); + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Artists") + Future getArtists({ + + /// Specify this to localize the search to a specific item or folder. Omit + /// to use the root. + @Query("ParentId") String? parentId, + + /// 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, + + /// 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, + + /// Optional. If enabled, only favorite artists will be returned. + @Query("IsFavorite") bool? isFavorite, + + }); + @FactoryConverter( request: JsonConverter.requestFactory, response: JsonConverter.responseFactory, diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart index e0857db80..9236ff32f 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -230,6 +230,57 @@ class JellyfinApiHelper { }); } + Future?> getArtists({ + BaseItemDto? parentItem, + String? sortBy, + String? sortOrder, + String? searchTerm, + String? filters, + String? fields, + /// 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 currentUserId = _finampUserHelper.currentUser?.id; + if (currentUserId == null) { + // When logging out, this request causes errors since currentUser is + // required sometimes. We just return an empty list since this error + // usually happens because the listeners on MusicScreenTabView update + // milliseconds before the page is popped. This shouldn't happen in normal + // use. + return []; + } + assert(!FinampSettingsHelper.finampSettings.isOffline); + fields ??= + defaultFields; // explicitly set the default fields, if we pass `null` to [JellyfinAPI.getItems] it will **not** apply the default fields, since the argument *is* provided. + + if (parentItem != null) { + _jellyfinApiHelperLogger.fine("Getting artists which are children of ${parentItem.name}"); + } else { + _jellyfinApiHelperLogger.fine("Getting artists."); + } + + return _runInIsolate((api) async { + dynamic response; + + response = await api.getArtists( + parentId: parentItem?.id, + searchTerm: searchTerm, + fields: fields, + sortBy: sortBy, + sortOrder: sortOrder, + filters: filters, + startIndex: startIndex, + limit: limit, + ); + + return QueryResult_BaseItemDto.fromJson(response).items; + }); + } + Future?> getLatestItems({ BaseItemDto? parentItem, String? includeItemTypes, diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index e31999ff0..ed7a88156 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -536,7 +536,8 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } } - return await _androidAutoHelper.searchItems(currentSearchQuery); + final results = await _androidAutoHelper.searchItems(currentSearchQuery); + return results; } diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 2f33c4485..d46767dad 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -34,7 +34,7 @@ class QueueService { final _jellyfinApiHelper = GetIt.instance(); final _audioHandler = GetIt.instance(); final _finampUserHelper = GetIt.instance(); - final _isarDownloader = GetIt.instance(); + final downloadsService = GetIt.instance(); final _queueServiceLogger = Logger("QueueService"); final _queuesBox = Hive.box("Queues"); @@ -361,7 +361,7 @@ class QueueService { if (FinampSettingsHelper.finampSettings.isOffline) { for (var id in missingIds) { jellyfin_models.BaseItemDto? item = - _isarDownloader.getSongDownload(id: id)?.baseItem; + downloadsService.getSongDownload(id: id)?.baseItem; if (item != null) { idMap[id] = item; } @@ -995,10 +995,25 @@ class QueueService { ); } - final downloadedSong = _isarDownloader.getSongDownload(item: item); + bool isDownloaded = false; + bool isItemPlayable = isPlayable?.call(item: item) ?? true; + DownloadItem? downloadedSong; + DownloadStub? downloadedCollection; DownloadItem? downloadedImage; + + if (item.type == "Audio") { + downloadedSong = downloadsService.getSongDownload(item: item); + isDownloaded = downloadedSong != null; + } else { + downloadedCollection = await downloadsService.getCollectionInfo(item: item); + if (downloadedCollection != null) { + final downloadStatus = downloadsService.getStatus(downloadedCollection, null); + isDownloaded = downloadStatus != DownloadItemStatus.notNeeded; + } + } + try { - downloadedImage = _isarDownloader.getImageDownload(item: item); + downloadedImage = downloadsService.getImageDownload(item: item); } catch (e) { _queueServiceLogger.warning("Couldn't get the offline image for track '${item.name}' because it's not downloaded or missing a blurhash"); } @@ -1037,7 +1052,7 @@ class QueueService { return MediaItem( id: itemId?.toString() ?? uuid.v4(), - playable: isPlayable?.call(item: item) ?? true, // this dictates whether clicking on an item will try to play it or browse it in media browsers like Android Auto + playable: isItemPlayable, // this dictates whether clicking on an item will try to play it or browse it in media browsers like Android Auto album: item.album, artist: item.artists?.join(", ") ?? item.albumArtist, artUri: artUri, @@ -1046,6 +1061,7 @@ class QueueService { "itemJson": item.toJson(setOffline: false), "shouldTranscode": FinampSettingsHelper.finampSettings.shouldTranscode, "downloadedSongPath": downloadedSong?.file?.path, + "android.media.extra.DOWNLOAD_STATUS": isDownloaded ? 2 : 0, "isOffline": FinampSettingsHelper.finampSettings.isOffline, "contextNormalizationGain": contextNormalizationGain, }, From 68d3b3d4e70193d7f2b44e5815ed801e661194f8 Mon Sep 17 00:00:00 2001 From: Chaphasilor Date: Fri, 31 May 2024 00:24:22 +0200 Subject: [PATCH 42/42] added like button to media notification, added settings for customizing media notification --- .../drawable-mdpi/baseline_shuffle_on_24.xml | 11 --- .../main/res/drawable/baseline_heart_24.xml | 11 +++ .../res/drawable/baseline_heart_filled_24.xml | 10 ++ .../main/res/drawable/baseline_shuffle_24.xml | 19 ++++ .../res/drawable/baseline_shuffle_on_24.xml | 22 +++++ .../baseline_stop_24.xml} | 4 +- lib/l10n/app_en.arb | 31 ++++-- lib/models/finamp_models.dart | 10 ++ lib/models/finamp_models.g.dart | 12 ++- .../customization_settings_screen.dart | 69 ++++++++++++- lib/screens/layout_settings_screen.dart | 6 +- lib/services/finamp_settings_helper.dart | 6 +- .../music_player_background_task.dart | 99 ++++++++++++++++--- 13 files changed, 269 insertions(+), 41 deletions(-) delete mode 100644 android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_24.xml create mode 100644 android/app/src/main/res/drawable/baseline_heart_24.xml create mode 100644 android/app/src/main/res/drawable/baseline_heart_filled_24.xml create mode 100644 android/app/src/main/res/drawable/baseline_shuffle_24.xml create mode 100644 android/app/src/main/res/drawable/baseline_shuffle_on_24.xml rename android/app/src/main/res/{drawable-mdpi/baseline_shuffle_24.xml => drawable/baseline_stop_24.xml} (57%) diff --git a/android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_24.xml b/android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_24.xml deleted file mode 100644 index 2c770f3f9..000000000 --- a/android/app/src/main/res/drawable-mdpi/baseline_shuffle_on_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/android/app/src/main/res/drawable/baseline_heart_24.xml b/android/app/src/main/res/drawable/baseline_heart_24.xml new file mode 100644 index 000000000..8670d8b29 --- /dev/null +++ b/android/app/src/main/res/drawable/baseline_heart_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/app/src/main/res/drawable/baseline_heart_filled_24.xml b/android/app/src/main/res/drawable/baseline_heart_filled_24.xml new file mode 100644 index 000000000..eb20a6249 --- /dev/null +++ b/android/app/src/main/res/drawable/baseline_heart_filled_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/baseline_shuffle_24.xml b/android/app/src/main/res/drawable/baseline_shuffle_24.xml new file mode 100644 index 000000000..724846b1d --- /dev/null +++ b/android/app/src/main/res/drawable/baseline_shuffle_24.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/baseline_shuffle_on_24.xml b/android/app/src/main/res/drawable/baseline_shuffle_on_24.xml new file mode 100644 index 000000000..7de7ad483 --- /dev/null +++ b/android/app/src/main/res/drawable/baseline_shuffle_on_24.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable-mdpi/baseline_shuffle_24.xml b/android/app/src/main/res/drawable/baseline_stop_24.xml similarity index 57% rename from android/app/src/main/res/drawable-mdpi/baseline_shuffle_24.xml rename to android/app/src/main/res/drawable/baseline_stop_24.xml index 2469a90ce..8d394e4fc 100644 --- a/android/app/src/main/res/drawable-mdpi/baseline_shuffle_24.xml +++ b/android/app/src/main/res/drawable/baseline_stop_24.xml @@ -5,6 +5,8 @@ android:viewportHeight="24" android:tint="?attr/colorControlNormal"> + android:pathData="M17 4h-10a3 3 0 0 0 -3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3 -3v-10a3 3 0 0 0 -3 -3z"/> diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 86bac07de..73ec7457a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -914,17 +914,17 @@ "@shuffleAllQueueSource": { "description": "Title for the queue source when the user is shuffling all tracks. Should be capitalized (if applicable) to be more recognizable throughout the UI" }, - "playbackOrderLinearButtonLabel": "Playing in order", + "playbackOrderLinearButtonLabel": "Playing in order. Tap to shuffle.", "@playbackOrderLinearButtonLabel": { "description": "Label for the button that toggles the playback order between linear/in-order and shuffle, while the queue is in linear/in-order mode" }, - "playbackOrderShuffledButtonLabel": "Shuffling tracks", + "playbackOrderShuffledButtonLabel": "Shuffling tracks. Tap to play in order.", "@playbackOrderShuffledButtonLabel": { "description": "Label for the button that toggles the playback order between linear/in-order and shuffle, while the queue is in shuffle mode" }, "playbackSpeedButtonLabel": "Playing at x{speed} speed", "@playbackSpeedButtonLabel": { - "description": "Label for the button that toggles visibility of the playback speed menu, {speed} is the current playback speed.", + "description": "Label for the button that toggles visibility. of the playback speed menu, {speed} is the current playback speed.", "placeholders": { "speed": { "type": "double" @@ -1550,13 +1550,28 @@ "fiveLatestAlbumsSettingSubtitle": "Downloads will be removed as they age out. Lock the download to prevent an album from being removed.", "cacheLibraryImagesSettings": "Cache current library images", "cacheLibraryImagesSettingsSubtitle": "All album, artist, genre, and playlist covers in the currently active library will be downloaded.", - "showProgressOnNowPlayingBarTitle": "Show track progress on now playing bar", + "showProgressOnNowPlayingBarTitle": "Show track progress on in-app miniplayer", "@showProgressOnNowPlayingBarTitle": { - "description": "Title for the setting that controls if the now playing bar / miniplayer at the botton of the music screen functions as a progress bar" + "description": "Title for the setting that controls if the in-app miniplayer / now playing bar at the botton of the music screen functions as a progress bar" }, - "showProgressOnNowPlayingBarSubtitle": "Controls if the now playing bar / miniplayer at the botton of the music screen functions as a progress bar.", + "showProgressOnNowPlayingBarSubtitle": "Controls if the in-app miniplayer / now playing bar at the botton of the music screen functions as a progress bar.", "@showProgressOnNowPlayingBarSubtitle": { - "description": "Subtitle for the setting that controls if the now playing bar / miniplayer at the botton of the music screen functions as a progress bar" + "description": "Subtitle for the setting that controls if the in-app miniplayer / now playing bar at the botton of the music screen functions as a progress bar" + }, + "showStopButtonOnMediaNotificationTitle": "Show stop button on media notification", + "@showStopButtonOnMediaNotificationTitle": { + "description": "Title for the setting that controls if the media notification has a stop button in addition to the pause button." + }, + "showStopButtonOnMediaNotificationSubtitle": "Controls if the media notification has a stop button in addition to the pause button. This lets you stop playback without opening the app.", + "@showStopButtonOnMediaNotificationSubtitle": { + "description": "Subtitle for the setting that controls if the media notification has a stop button in addition to the pause button." + }, + "showSeekControlsOnMediaNotificationTitle": "Show seek controls on media notification", + "@showSeekControlsOnMediaNotificationTitle": { + "description": "Title for the setting that controls if the media notification has a seekable progress bar." + }, + "showSeekControlsOnMediaNotificationSubtitle": "Controls if the media notification has a seekable progress bar. This lets you change the playback position without opening the app.", + "@showSeekControlsOnMediaNotificationSubtitle": { + "description": "Subtitle for the setting that controls if the media notification has a seekable progress bar." } - } diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index c05b0aa0a..4220a84d7 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -105,6 +105,8 @@ const _showArtistChipImage = true; const _trackOfflineFavoritesDefault = true; const _showProgressOnNowPlayingBarDefault = true; const _startInstantMixForIndividualTracksDefault = true; +const _showStopButtonOnMediaNotificationDefault = false; +const _showSeekControlsOnMediaNotificationDefault = true; @HiveType(typeId: 28) class FinampSettings { @@ -178,6 +180,8 @@ class FinampSettings { this.trackOfflineFavorites = _trackOfflineFavoritesDefault, this.showProgressOnNowPlayingBar = _showProgressOnNowPlayingBarDefault, this.startInstantMixForIndividualTracks = _startInstantMixForIndividualTracksDefault, + this.showStopButtonOnMediaNotification = _showStopButtonOnMediaNotificationDefault, + this.showSeekControlsOnMediaNotification = _showSeekControlsOnMediaNotificationDefault, }); @HiveField(0, defaultValue: _isOfflineDefault) @@ -391,6 +395,12 @@ class FinampSettings { @HiveField(65, defaultValue: _startInstantMixForIndividualTracksDefault) bool startInstantMixForIndividualTracks; + @HiveField(66, defaultValue: _showStopButtonOnMediaNotificationDefault) + bool showStopButtonOnMediaNotification; + + @HiveField(67, defaultValue: _showSeekControlsOnMediaNotificationDefault) + bool showSeekControlsOnMediaNotification; + static Future create() async { final downloadLocation = await DownloadLocation.create( name: "Internal Storage", diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index c2d268e4f..e07b0cbbf 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -167,6 +167,10 @@ class FinampSettingsAdapter extends TypeAdapter { fields[64] == null ? true : fields[64] as bool, startInstantMixForIndividualTracks: fields[65] == null ? true : fields[65] as bool, + showStopButtonOnMediaNotification: + fields[66] == null ? false : fields[66] as bool, + showSeekControlsOnMediaNotification: + fields[67] == null ? true : fields[67] as bool, ) ..disableGesture = fields[19] == null ? false : fields[19] as bool ..showFastScroller = fields[25] == null ? true : fields[25] as bool @@ -176,7 +180,7 @@ class FinampSettingsAdapter extends TypeAdapter { @override void write(BinaryWriter writer, FinampSettings obj) { writer - ..writeByte(64) + ..writeByte(66) ..writeByte(0) ..write(obj.isOffline) ..writeByte(1) @@ -304,7 +308,11 @@ class FinampSettingsAdapter extends TypeAdapter { ..writeByte(64) ..write(obj.showProgressOnNowPlayingBar) ..writeByte(65) - ..write(obj.startInstantMixForIndividualTracks); + ..write(obj.startInstantMixForIndividualTracks) + ..writeByte(66) + ..write(obj.showStopButtonOnMediaNotification) + ..writeByte(67) + ..write(obj.showSeekControlsOnMediaNotification); } @override diff --git a/lib/screens/customization_settings_screen.dart b/lib/screens/customization_settings_screen.dart index 2e344203b..d8b7d7f21 100644 --- a/lib/screens/customization_settings_screen.dart +++ b/lib/screens/customization_settings_screen.dart @@ -1,7 +1,11 @@ +import 'dart:io'; + import 'package:finamp/components/LayoutSettingsScreen/CustomizationSettingsScreen/playback_speed_control_visibility_dropdown_list_tile.dart'; +import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; class CustomizationSettingsScreen extends StatefulWidget { const CustomizationSettingsScreen({Key? key}) : super(key: key); @@ -33,10 +37,71 @@ class _CustomizationSettingsScreenState ], ), body: ListView( - children: const [ - PlaybackSpeedControlVisibilityDropdownListTile(), + children: [ + const PlaybackSpeedControlVisibilityDropdownListTile(), + if (!Platform.isIOS) + const ShowStopButtonOnMediaNotificationToggle(), + const ShowSeekControlsOnMediaNotificationToggle(), ], ), ); } } + +class ShowStopButtonOnMediaNotificationToggle extends StatelessWidget { + const ShowStopButtonOnMediaNotificationToggle({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, child) { + bool? showStopButtonOnMediaNotification = box.get("FinampSettings")?.showStopButtonOnMediaNotification; + + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.showStopButtonOnMediaNotificationTitle), + subtitle: + Text(AppLocalizations.of(context)!.showStopButtonOnMediaNotificationSubtitle), + value: showStopButtonOnMediaNotification ?? false, + onChanged: showStopButtonOnMediaNotification == null + ? null + : (value) { + FinampSettings finampSettingsTemp = + box.get("FinampSettings")!; + finampSettingsTemp.showStopButtonOnMediaNotification = value; + box.put("FinampSettings", finampSettingsTemp); + }, + ); + }, + ); + } +} + +class ShowSeekControlsOnMediaNotificationToggle extends StatelessWidget { + const ShowSeekControlsOnMediaNotificationToggle({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, child) { + bool? showSeekControlsOnMediaNotification = box.get("FinampSettings")?.showSeekControlsOnMediaNotification; + + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.showSeekControlsOnMediaNotificationTitle), + subtitle: + Text(AppLocalizations.of(context)!.showSeekControlsOnMediaNotificationSubtitle), + value: showSeekControlsOnMediaNotification ?? false, + onChanged: showSeekControlsOnMediaNotification == null + ? null + : (value) { + FinampSettings finampSettingsTemp = + box.get("FinampSettings")!; + finampSettingsTemp.showSeekControlsOnMediaNotification = value; + box.put("FinampSettings", finampSettingsTemp); + }, + ); + }, + ); + } +} diff --git a/lib/screens/layout_settings_screen.dart b/lib/screens/layout_settings_screen.dart index b901c82a0..94d44d2f1 100644 --- a/lib/screens/layout_settings_screen.dart +++ b/lib/screens/layout_settings_screen.dart @@ -66,7 +66,7 @@ class LayoutSettingsScreen extends StatelessWidget { const ShowArtistChipImageToggle(), const AllowSplitScreenSwitch(), const HideSongArtistsIfSameAsAlbumArtistsSelector(), - const ShowProgressOnNowPlayingBar(), + const ShowProgressOnNowPlayingBarToggle(), ], ), ); @@ -167,8 +167,8 @@ class FixedGridTileSizeDropdownListTile extends StatelessWidget { } } -class ShowProgressOnNowPlayingBar extends StatelessWidget { - const ShowProgressOnNowPlayingBar({super.key}); +class ShowProgressOnNowPlayingBarToggle extends StatelessWidget { + const ShowProgressOnNowPlayingBarToggle({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/services/finamp_settings_helper.dart b/lib/services/finamp_settings_helper.dart index a452c2ffc..a27da8b85 100644 --- a/lib/services/finamp_settings_helper.dart +++ b/lib/services/finamp_settings_helper.dart @@ -324,8 +324,10 @@ class FinampSettingsHelper { static void resetCustomizationSettings() { FinampSettings finampSettingsTemp = finampSettings; - finampSettingsTemp.playbackSpeedVisibility = - PlaybackSpeedVisibility.automatic; + //TODO refactor this so default settings are available here + finampSettingsTemp.playbackSpeedVisibility = PlaybackSpeedVisibility.automatic; + finampSettingsTemp.showStopButtonOnMediaNotification = false; + finampSettingsTemp.showSeekControlsOnMediaNotification = true; Hive.box("FinampSettings") .put("FinampSettings", finampSettingsTemp); } diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index ed7a88156..94b6a2b96 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -4,8 +4,12 @@ import 'dart:io'; import 'dart:math'; import 'dart:ui'; +import 'package:finamp/components/global_snackbar.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; +import 'package:finamp/services/favorite_provider.dart'; +import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:finamp/services/queue_service.dart'; @@ -560,6 +564,48 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { case CustomPlaybackActions.shuffle: final queueService = GetIt.instance(); return queueService.togglePlaybackOrder(); + case CustomPlaybackActions.toggleFavorite: + jellyfin_models.BaseItemDto? currentItem; + + if (mediaItem.valueOrNull?.extras?["itemJson"] != null) { + currentItem = jellyfin_models.BaseItemDto.fromJson(mediaItem.valueOrNull?.extras!["itemJson"] as Map); + } else { + return; + } + + bool isFavorite = currentItem.userData?.isFavorite ?? false; + if (GlobalSnackbar.materialAppScaffoldKey.currentContext != null) { + // get current favorite status from the provider + isFavorite = ProviderScope.containerOf(GlobalSnackbar.materialAppScaffoldKey.currentContext!, listen: false) + .read(isFavoriteProvider(FavoriteRequest(currentItem))); + // update favorite status with the value returned by the provider + isFavorite = ProviderScope.containerOf(GlobalSnackbar.materialAppScaffoldKey.currentContext!, listen: false) + .read(isFavoriteProvider(FavoriteRequest(currentItem)).notifier) + .updateFavorite(!isFavorite); + } else { + // fallback if we can't find the context + final jellyfinApiHelper = GetIt.instance(); + if (isFavorite) { + await jellyfinApiHelper.removeFavourite(currentItem.id); + } else { + await jellyfinApiHelper.addFavourite(currentItem.id); + } + isFavorite = !isFavorite; + final newUserData = currentItem.userData; + if (newUserData != null) { + newUserData.isFavorite = isFavorite; + } + currentItem.userData = newUserData; + mediaItem.add(mediaItem.valueOrNull?.copyWith( + extras: { + ...mediaItem.valueOrNull?.extras ?? {}, + "itemJson": currentItem.toJson(), + }, + )); + } + // re-trigger the playbackState update to update the notification + final event = _transformEvent(_player.playbackEvent); + return playbackState.add(event); default: // NOP, handled below } @@ -654,27 +700,56 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { /// 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) { + + jellyfin_models.BaseItemDto? currentItem; + bool isFavorite = false; + + if (mediaItem.valueOrNull?.extras?["itemJson"] != null) { + currentItem = jellyfin_models.BaseItemDto.fromJson(mediaItem.valueOrNull?.extras!["itemJson"] as Map); + if (GlobalSnackbar.materialAppScaffoldKey.currentContext != null) { + isFavorite = ProviderScope.containerOf(GlobalSnackbar.materialAppScaffoldKey.currentContext!, listen: false) + .read(isFavoriteProvider(FavoriteRequest(currentItem))); + } else { + isFavorite = currentItem.userData?.isFavorite ?? false; + } + } + + return PlaybackState( controls: [ MediaControl.skipToPrevious, if (_player.playing) MediaControl.pause else MediaControl.play, - // MediaControl.stop.copyWith( - // androidIcon: "drawable/baseline_shuffle_on_24"), - MediaControl.stop, MediaControl.skipToNext, MediaControl.custom( - androidIcon: _player.shuffleModeEnabled - ? "drawable/baseline_shuffle_on_24" - : "drawable/baseline_shuffle_24", - label: "Shuffle", - name: CustomPlaybackActions.shuffle.name, - )], - systemActions: const { + name: CustomPlaybackActions.toggleFavorite.name, + androidIcon: isFavorite + ? "drawable/baseline_heart_filled_24" + : "drawable/baseline_heart_24", + label: isFavorite ? + (GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.removeFavourite : "Remove favorite") : + (GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.addFavourite : "Add favorite"), + ), + //!!! Android Auto adds a shuffle toggle button automatically, adding it here would result in a duplicate button + // MediaControl.custom( + // name: CustomPlaybackActions.shuffle.name, + // androidIcon: _player.shuffleModeEnabled + // ? "drawable/baseline_shuffle_on_24" + // : "drawable/baseline_shuffle_24", + // label: _player.shuffleModeEnabled ? + // (GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.playbackOrderShuffledButtonLabel : "Shuffle enabled") : + // (GlobalSnackbar.materialAppScaffoldKey.currentContext != null ? AppLocalizations.of(GlobalSnackbar.materialAppScaffoldKey.currentContext!)!.playbackOrderLinearButtonLabel : "Shuffle disabled"), + // ), + if (FinampSettingsHelper.finampSettings.showStopButtonOnMediaNotification) + MediaControl.stop.copyWith( + androidIcon: "drawable/baseline_stop_24"), + // MediaControl.stop, + ], + systemActions: FinampSettingsHelper.finampSettings.showSeekControlsOnMediaNotification ? const { MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward, - }, - androidCompactActionIndices: const [0, 1, 3], + } : {}, + androidCompactActionIndices: const [0, 1, 2], processingState: const { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading,