From 32791517c74bf1ce76a389f9dbfdc8b2624f202d Mon Sep 17 00:00:00 2001 From: raykast Date: Sun, 6 Aug 2023 23:34:08 -0700 Subject: [PATCH 1/5] Add Play Next & Add to Queue for songs and albums. --- .../AlbumScreen/song_list_tile.dart | 22 +++++++++- lib/components/MusicScreen/album_item.dart | 43 +++++++++++++++++++ lib/l10n/app_en.arb | 16 ++++++- lib/services/audio_service_helper.dart | 29 +++++++++++-- .../music_player_background_task.dart | 29 ++++++++++++- 5 files changed, 131 insertions(+), 8 deletions(-) diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index bef409cc8..77058a47b 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -20,6 +20,7 @@ import 'downloaded_indicator.dart'; enum SongListTileMenuItems { addToQueue, + playNext, replaceQueueWithItem, addToPlaylist, removeFromPlaylist, @@ -245,6 +246,13 @@ class _SongListTileState extends State { title: Text(AppLocalizations.of(context)!.addToQueue), ), ), + PopupMenuItem( + value: SongListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), PopupMenuItem( value: SongListTileMenuItems.replaceQueueWithItem, child: ListTile( @@ -324,7 +332,7 @@ class _SongListTileState extends State { switch (selection) { case SongListTileMenuItems.addToQueue: - await _audioServiceHelper.addQueueItem(widget.item); + await _audioServiceHelper.addQueueItems([widget.item]); if (!mounted) return; @@ -333,6 +341,16 @@ class _SongListTileState extends State { )); break; + case SongListTileMenuItems.playNext: + await _audioServiceHelper.insertQueueItemsNext([widget.item]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.insertedIntoQueue), + )); + break; + case SongListTileMenuItems.replaceQueueWithItem: await _audioServiceHelper .replaceQueueWithItem(itemList: [widget.item]); @@ -451,7 +469,7 @@ class _SongListTileState extends State { ), ), confirmDismiss: (direction) async { - await _audioServiceHelper.addQueueItem(widget.item); + await _audioServiceHelper.addQueueItems([widget.item]); if (!mounted) return false; diff --git a/lib/components/MusicScreen/album_item.dart b/lib/components/MusicScreen/album_item.dart index b98843017..f93d726a0 100644 --- a/lib/components/MusicScreen/album_item.dart +++ b/lib/components/MusicScreen/album_item.dart @@ -5,6 +5,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; import '../../models/jellyfin_models.dart'; +import '../../services/audio_service_helper.dart'; import '../../services/jellyfin_api_helper.dart'; import '../../screens/artist_screen.dart'; import '../../screens/album_screen.dart'; @@ -12,6 +13,8 @@ import '../error_snackbar.dart'; import 'album_item_card.dart'; enum _AlbumListTileMenuItems { + addToQueue, + playNext, addFavourite, removeFavourite, addToMixList, @@ -58,6 +61,8 @@ class AlbumItem extends StatefulWidget { } class _AlbumItemState extends State { + final _audioServiceHelper = GetIt.instance(); + late BaseItemDto mutableAlbum; late Function() onTap; @@ -110,6 +115,20 @@ class _AlbumItemState extends State { screenSize.height - details.globalPosition.dy, ), items: [ + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.addToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.addToQueue), + ), + ), + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), mutableAlbum.userData!.isFavorite ? PopupMenuItem<_AlbumListTileMenuItems>( value: _AlbumListTileMenuItems.removeFavourite, @@ -148,6 +167,30 @@ class _AlbumItemState extends State { if (!mounted) return; switch (selection) { + case _AlbumListTileMenuItems.addToQueue: + final children = await jellyfinApiHelper.getItems( + parentItem: widget.album, isGenres: false); + await _audioServiceHelper.addQueueItems(children!); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.addedToQueue), + )); + break; + + case _AlbumListTileMenuItems.playNext: + final children = await jellyfinApiHelper.getItems( + parentItem: widget.album, isGenres: false); + await _audioServiceHelper.insertQueueItemsNext(children!); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.insertedIntoQueue), + )); + break; + case _AlbumListTileMenuItems.addFavourite: try { final newUserData = diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2f65ccc86..b2f3ee0e7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -417,7 +417,13 @@ "queue": "Queue", "@queue": {}, "addToQueue": "Add to Queue", - "@addToQueue": {}, + "@addToQueue": { + "description": "Popup menu item title for adding an item to the end of the play queue." + }, + "playNext": "Play Next", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, "replaceQueue": "Replace Queue", "@replaceQueue": {}, "instantMix": "Instant Mix", @@ -429,7 +435,13 @@ "addFavourite": "Add Favourite", "@addFavourite": {}, "addedToQueue": "Added to queue.", - "@addedToQueue": {}, + "@addedToQueue": { + "description": "Snackbar message that shows when the user successfully adds items to the end of the play queue." + }, + "insertedIntoQueue": "Inserted into queue.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, "queueReplaced": "Queue replaced.", "@queueReplaced": {}, "removedFromPlaylist": "Removed from playlist.", diff --git a/lib/services/audio_service_helper.dart b/lib/services/audio_service_helper.dart index 989ee97a3..7439b55d1 100644 --- a/lib/services/audio_service_helper.dart +++ b/lib/services/audio_service_helper.dart @@ -64,17 +64,40 @@ class AudioServiceHelper { } } + @Deprecated("Use addQueueItems instead") Future addQueueItem(BaseItemDto item) async { + await addQueueItems([item]); + } + + Future addQueueItems(List items) async { try { // If the queue is empty (like when the app is first launched), run the // replace queue function instead so that the song gets played if ((_audioHandler.queue.valueOrNull?.length ?? 0) == 0) { - await replaceQueueWithItem(itemList: [item]); + await replaceQueueWithItem(itemList: items); + return; + } + + final mediaItems = + await Future.wait(items.map((i) => _generateMediaItem(i))); + await _audioHandler.addQueueItems(mediaItems); + } catch (e) { + audioServiceHelperLogger.severe(e); + return Future.error(e); + } + } + + Future insertQueueItemsNext(List items) async { + try { + // See above comment in addQueueItem + if ((_audioHandler.queue.valueOrNull?.length ?? 0) == 0) { + await replaceQueueWithItem(itemList: items); return; } - final itemMediaItem = await _generateMediaItem(item); - await _audioHandler.addQueueItem(itemMediaItem); + final mediaItems = + await Future.wait(items.map((i) => _generateMediaItem(i))); + await _audioHandler.insertQueueItemsNext(mediaItems); } catch (e) { audioServiceHelperLogger.severe(e); return Future.error(e); diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 1e9c6f79b..a4ba51297 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -198,9 +198,36 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } @override + @Deprecated("Use addQueueItems instead") Future addQueueItem(MediaItem mediaItem) async { + addQueueItems([mediaItem]); + } + + @override + Future addQueueItems(List mediaItems) async { try { - await _queueAudioSource.add(await _mediaItemToAudioSource(mediaItem)); + final sources = + await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i))); + await _queueAudioSource.addAll(sources); + queue.add(_queueFromSource()); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + Future insertQueueItemsNext(List mediaItems) async { + try { + int? idx = _player.nextIndex; + if (idx == null) { + idx = _player.currentIndex; + if (idx != null) ++idx; + } + idx ??= 0; + + final sources = + await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i))); + await _queueAudioSource.insertAll(idx, sources); queue.add(_queueFromSource()); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); From e324fd33d6c0900f890ffc52e65833563d693008 Mon Sep 17 00:00:00 2001 From: raykast Date: Mon, 7 Aug 2023 00:37:55 -0700 Subject: [PATCH 2/5] nextIndex causes bug with repeat-one looping. --- lib/services/music_player_background_task.dart | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index a4ba51297..68cda95cd 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -218,16 +218,10 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { Future insertQueueItemsNext(List mediaItems) async { try { - int? idx = _player.nextIndex; - if (idx == null) { - idx = _player.currentIndex; - if (idx != null) ++idx; - } - idx ??= 0; - final sources = await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i))); - await _queueAudioSource.insertAll(idx, sources); + await _queueAudioSource.insertAll( + (_player.currentIndex ?? -1) + 1, sources); queue.add(_queueFromSource()); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); From e69b201e91e5e468fd8301311268c10ec0668eed Mon Sep 17 00:00:00 2001 From: raykast Date: Mon, 7 Aug 2023 00:43:39 -0700 Subject: [PATCH 3/5] Copy album queue sort and limit from album screen. --- lib/components/MusicScreen/album_item.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/components/MusicScreen/album_item.dart b/lib/components/MusicScreen/album_item.dart index f93d726a0..93cd8fb62 100644 --- a/lib/components/MusicScreen/album_item.dart +++ b/lib/components/MusicScreen/album_item.dart @@ -169,7 +169,11 @@ class _AlbumItemState extends State { switch (selection) { case _AlbumListTileMenuItems.addToQueue: final children = await jellyfinApiHelper.getItems( - parentItem: widget.album, isGenres: false); + parentItem: widget.album, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + isGenres: false, + ); await _audioServiceHelper.addQueueItems(children!); if (!mounted) return; @@ -181,7 +185,11 @@ class _AlbumItemState extends State { case _AlbumListTileMenuItems.playNext: final children = await jellyfinApiHelper.getItems( - parentItem: widget.album, isGenres: false); + parentItem: widget.album, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + isGenres: false, + ); await _audioServiceHelper.insertQueueItemsNext(children!); if (!mounted) return; From 6ae1af948ebbab7c1fa80cadf27d25cef2f51d2e Mon Sep 17 00:00:00 2001 From: raykast Date: Mon, 7 Aug 2023 03:40:10 -0700 Subject: [PATCH 4/5] Change shuffle order to make queue edits intuitive --- .../music_player_background_task.dart | 82 ++++++++++++++++++- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 68cda95cd..45afe3069 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:android_id/android_id.dart'; import 'package:audio_service/audio_service.dart'; @@ -15,6 +16,66 @@ import 'finamp_settings_helper.dart'; import 'finamp_user_helper.dart'; import 'jellyfin_api_helper.dart'; +// Largely copied from just_audio's DefaultShuffleOrder, but with a mildly +// stupid hack to insert() to make Play Next work +class FinampShuffleOrder extends ShuffleOrder { + final Random _random; + @override + final indices = []; + + FinampShuffleOrder({Random? random}) : _random = random ?? Random(); + + @override + void shuffle({int? initialIndex}) { + assert(initialIndex == null || indices.contains(initialIndex)); + if (indices.length <= 1) return; + indices.shuffle(_random); + if (initialIndex == null) return; + + const initialPos = 0; + final swapPos = indices.indexOf(initialIndex); + // Swap the indices at initialPos and swapPos. + final swapIndex = indices[initialPos]; + indices[initialPos] = initialIndex; + indices[swapPos] = swapIndex; + } + + @override + void insert(int index, int count) { + // Offset indices after insertion point. + for (var i = 0; i < indices.length; i++) { + if (indices[i] >= index) { + indices[i] += count; + } + } + + final newIndices = List.generate(count, (i) => index + i); + // This is the only modification from DefaultShuffleOrder: Only shuffle + // inserted indices amongst themselves, but keep them contiguous + newIndices.shuffle(_random); + indices.insertAll(index, newIndices); + } + + @override + void removeRange(int start, int end) { + final count = end - start; + // Remove old indices. + final oldIndices = List.generate(count, (i) => start + i).toSet(); + indices.removeWhere(oldIndices.contains); + // Offset indices after deletion point. + for (var i = 0; i < indices.length; i++) { + if (indices[i] >= end) { + indices[i] -= count; + } + } + } + + @override + void clear() { + indices.clear(); + } +} + /// This provider handles the currently playing music so that multiple widgets /// can control music. class MusicPlayerBackgroundTask extends BaseAudioHandler { @@ -30,8 +91,10 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { FinampSettingsHelper.finampSettings.bufferDuration, )), ); - ConcatenatingAudioSource _queueAudioSource = - ConcatenatingAudioSource(children: []); + ConcatenatingAudioSource _queueAudioSource = ConcatenatingAudioSource( + children: [], + shuffleOrder: FinampShuffleOrder(), + ); final _audioServiceBackgroundTaskLogger = Logger("MusicPlayerBackgroundTask"); final _jellyfinApiHelper = GetIt.instance(); final _finampUserHelper = GetIt.instance(); @@ -218,10 +281,20 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { Future insertQueueItemsNext(List mediaItems) async { try { + var idx = _player.currentIndex; + if (idx != null) { + if (_player.shuffleModeEnabled) { + var next = _player.shuffleIndices?.indexOf(idx); + idx = next == -1 || next == null ? null : next + 1; + } else { + ++idx; + } + } + idx ??= 0; + final sources = await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i))); - await _queueAudioSource.insertAll( - (_player.currentIndex ?? -1) + 1, sources); + await _queueAudioSource.insertAll(idx, sources); queue.add(_queueFromSource()); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); @@ -241,6 +314,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // Create a new ConcatenatingAudioSource with the new queue. _queueAudioSource = ConcatenatingAudioSource( children: audioSources, + shuffleOrder: FinampShuffleOrder(), ); try { From 7b0f169b66fee9d449626a0efd377a3a78eeec46 Mon Sep 17 00:00:00 2001 From: raykast Date: Mon, 7 Aug 2023 12:31:33 -0700 Subject: [PATCH 5/5] Fix inconsistent popup menu items. --- .../AlbumScreen/song_list_tile.dart | 26 ++++++------ lib/components/MusicScreen/album_item.dart | 40 +++++++++++-------- lib/services/audio_service_helper.dart | 4 ++ 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index 77058a47b..385d4ec45 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -239,20 +239,22 @@ class _SongListTileState extends State { screenSize.height - details.globalPosition.dy, ), items: [ - PopupMenuItem( - value: SongListTileMenuItems.addToQueue, - child: ListTile( - leading: const Icon(Icons.queue_music), - title: Text(AppLocalizations.of(context)!.addToQueue), + if (_audioServiceHelper.hasQueueItems()) ...[ + PopupMenuItem( + value: SongListTileMenuItems.addToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.addToQueue), + ), ), - ), - PopupMenuItem( - value: SongListTileMenuItems.playNext, - child: ListTile( - leading: const Icon(Icons.queue_music), - title: Text(AppLocalizations.of(context)!.playNext), + PopupMenuItem( + value: SongListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.playNext), + ), ), - ), + ], PopupMenuItem( value: SongListTileMenuItems.replaceQueueWithItem, child: ListTile( diff --git a/lib/components/MusicScreen/album_item.dart b/lib/components/MusicScreen/album_item.dart index 93cd8fb62..db339f37c 100644 --- a/lib/components/MusicScreen/album_item.dart +++ b/lib/components/MusicScreen/album_item.dart @@ -98,11 +98,7 @@ class _AlbumItemState extends State { onLongPressStart: (details) async { Feedback.forLongPress(context); - if (FinampSettingsHelper.finampSettings.isOffline) { - // If offline, don't show the context menu since the only options here - // are for online. - return; - } + final isOffline = FinampSettingsHelper.finampSettings.isOffline; final jellyfinApiHelper = GetIt.instance(); @@ -115,48 +111,58 @@ class _AlbumItemState extends State { screenSize.height - details.globalPosition.dy, ), items: [ - PopupMenuItem<_AlbumListTileMenuItems>( - value: _AlbumListTileMenuItems.addToQueue, - child: ListTile( - leading: const Icon(Icons.queue_music), - title: Text(AppLocalizations.of(context)!.addToQueue), + if (_audioServiceHelper.hasQueueItems()) ...[ + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.addToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.addToQueue), + ), ), - ), - PopupMenuItem<_AlbumListTileMenuItems>( - value: _AlbumListTileMenuItems.playNext, - child: ListTile( - leading: const Icon(Icons.queue_music), - title: Text(AppLocalizations.of(context)!.playNext), + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.playNext), + ), ), - ), + ], mutableAlbum.userData!.isFavorite ? PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, value: _AlbumListTileMenuItems.removeFavourite, child: ListTile( + enabled: !isOffline, leading: const Icon(Icons.favorite_border), title: Text(AppLocalizations.of(context)!.removeFavourite), ), ) : PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, value: _AlbumListTileMenuItems.addFavourite, child: ListTile( + enabled: !isOffline, leading: const Icon(Icons.favorite), title: Text(AppLocalizations.of(context)!.addFavourite), ), ), jellyfinApiHelper.selectedMixAlbumIds.contains(mutableAlbum.id) ? PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, value: _AlbumListTileMenuItems.removeFromMixList, child: ListTile( + enabled: !isOffline, leading: const Icon(Icons.explore_off), title: Text(AppLocalizations.of(context)!.removeFromMix), ), ) : PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, value: _AlbumListTileMenuItems.addToMixList, child: ListTile( + enabled: !isOffline, leading: const Icon(Icons.explore), title: Text(AppLocalizations.of(context)!.addToMix), ), diff --git a/lib/services/audio_service_helper.dart b/lib/services/audio_service_helper.dart index 7439b55d1..925bb2111 100644 --- a/lib/services/audio_service_helper.dart +++ b/lib/services/audio_service_helper.dart @@ -64,6 +64,10 @@ class AudioServiceHelper { } } + bool hasQueueItems() { + return (_audioHandler.queue.valueOrNull?.length ?? 0) != 0; + } + @Deprecated("Use addQueueItems instead") Future addQueueItem(BaseItemDto item) async { await addQueueItems([item]);