diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 276a25e8a..fc6064434 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -3,4 +3,5 @@ to allow setting breakpoints, to provide hot reload, etc. --> + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd4bfe7b4..003a2116b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + diff --git a/lib/at_contrast.dart b/lib/at_contrast.dart index 3b0686475..6f98007a6 100644 --- a/lib/at_contrast.dart +++ b/lib/at_contrast.dart @@ -24,25 +24,25 @@ extension AtContrast on Color { double minLightness = 0.0; double maxLightness = 1.0; - double diff = contrast - targetContrast; + double diff = contrast.abs() - targetContrast.abs(); int steps = 0; + int maxSteps = 25; - while (diff.abs() > _tolerance) { + // If diff is negative, we need more contrast. + while (diff < -_tolerance && steps < maxSteps) { steps++; - print("$steps $diff"); - // If diff is negative, we need more contrast. Otherwise, we need less + print("contrast: $steps $diff"); if (diff.isNegative) { - minLightness = hslColor.lightness; + if (lighter) { + minLightness = hslColor.lightness; + } else { + maxLightness = hslColor.lightness; + } - final lightDiff = maxLightness - hslColor.lightness; + final lightDiff = + lighter ? maxLightness - minLightness : minLightness - maxLightness; hslColor = hslColor.withLightness(hslColor.lightness + lightDiff / 2); - } else { - maxLightness = hslColor.lightness; - - final lightDiff = hslColor.lightness - minLightness; - - hslColor = hslColor.withLightness(hslColor.lightness - lightDiff / 2); } contrast = contrastRatio( @@ -50,7 +50,7 @@ extension AtContrast on Color { backgroundLuminance, ); - diff = (contrast - targetContrast); + diff = (contrast.abs() - targetContrast.abs()); } _atContrastLogger.info("Calculated contrast in $steps steps"); diff --git a/lib/components/AddToPlaylistScreen/add_to_playlist_list.dart b/lib/components/AddToPlaylistScreen/add_to_playlist_list.dart index 242669405..38a9df820 100644 --- a/lib/components/AddToPlaylistScreen/add_to_playlist_list.dart +++ b/lib/components/AddToPlaylistScreen/add_to_playlist_list.dart @@ -45,6 +45,7 @@ class _AddToPlaylistListState extends State { return AlbumItem( album: snapshot.data![index], parentType: snapshot.data![index].type, + isPlaylist: true, onTap: () async { try { await jellyfinApiHelper.addItemstoPlaylist( diff --git a/lib/components/AlbumScreen/album_screen_content.dart b/lib/components/AlbumScreen/album_screen_content.dart index fd96755df..446a9b21a 100644 --- a/lib/components/AlbumScreen/album_screen_content.dart +++ b/lib/components/AlbumScreen/album_screen_content.dart @@ -62,14 +62,15 @@ class _AlbumScreenContentState extends State { SliverAppBar( title: Text(widget.parent.name ?? AppLocalizations.of(context)!.unknownName), - // 125 + 64 is the total height of the widget we use as a + // 125 + 186 is the total height of the widget we use as a // FlexibleSpaceBar. We add the toolbar height since the widget // should appear below the appbar. // TODO: This height is affected by platform density. - expandedHeight: kToolbarHeight + 125 + 64, + expandedHeight: kToolbarHeight + 125 + 186, pinned: true, flexibleSpace: AlbumScreenContentFlexibleSpaceBar( - album: widget.parent, + parentItem: widget.parent, + isPlaylist: widget.parent.type == "Playlist", items: widget.children, ), actions: [ @@ -172,6 +173,7 @@ class _SongsSliverListState extends State { children: widget.childrenForQueue, index: index + indexOffset, parentId: widget.parent.id, + parentName: widget.parent.name, onDelete: () { final item = removeItem(); if (widget.onDelete != null) { diff --git a/lib/components/AlbumScreen/album_screen_content_flexible_space_bar.dart b/lib/components/AlbumScreen/album_screen_content_flexible_space_bar.dart index e5e4cf012..fc55a7e57 100644 --- a/lib/components/AlbumScreen/album_screen_content_flexible_space_bar.dart +++ b/lib/components/AlbumScreen/album_screen_content_flexible_space_bar.dart @@ -1,5 +1,7 @@ import 'dart:math'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; @@ -12,17 +14,195 @@ import 'item_info.dart'; class AlbumScreenContentFlexibleSpaceBar extends StatelessWidget { const AlbumScreenContentFlexibleSpaceBar({ Key? key, - required this.album, + required this.parentItem, + required this.isPlaylist, required this.items, }) : super(key: key); - final BaseItemDto album; + final BaseItemDto parentItem; + final bool isPlaylist; final List items; @override Widget build(BuildContext context) { - AudioServiceHelper audioServiceHelper = - GetIt.instance(); + GetIt.instance(); + QueueService queueService = GetIt.instance(); + + void playAlbum() { + queueService.startPlayback( + items: items, + source: QueueItemSource( + type: isPlaylist + ? QueueItemSourceType.playlist + : QueueItemSourceType.album, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: parentItem.id, + item: parentItem, + ), + order: FinampPlaybackOrder.linear, + ); + } + + void shuffleAlbum() { + queueService.startPlayback( + items: items, + source: QueueItemSource( + type: isPlaylist + ? QueueItemSourceType.playlist + : QueueItemSourceType.album, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: parentItem.id, + item: parentItem, + ), + order: FinampPlaybackOrder.shuffled, + ); + } + + void addAlbumToNextUp() { + queueService.addToNextUp( + items: items, + source: QueueItemSource( + type: isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: parentItem.id, + item: parentItem, + ), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .confirmAddToNextUp(isPlaylist ? "playlist" : "album")), + ), + ); + } + + void addAlbumNext() { + queueService.addNext( + items: items, + source: QueueItemSource( + type: isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: parentItem.id, + item: parentItem, + )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .confirmPlayNext(isPlaylist ? "playlist" : "album")), + ), + ); + } + + void shuffleAlbumToNextUp() { + // linear order is used in this case since we don't want to affect the rest of the queue + List clonedItems = List.from(items); + clonedItems.shuffle(); + queueService.addToNextUp( + items: clonedItems, + source: QueueItemSource( + type: isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: parentItem.id, + item: parentItem, + )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.confirmShuffleToNextUp), + ), + ); + } + + void shuffleAlbumNext() { + // linear order is used in this case since we don't want to affect the rest of the queue + List clonedItems = List.from(items); + clonedItems.shuffle(); + queueService.addNext( + items: clonedItems, + source: QueueItemSource( + type: isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: parentItem.id, + item: parentItem, + )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.confirmShuffleNext), + ), + ); + } + + void addAlbumToQueue() { + queueService.addToQueue( + items: items, + source: QueueItemSource( + type: isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: parentItem.id, + item: parentItem, + ), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)! + .confirmAddToQueue(isPlaylist ? "playlist" : "album")), + ), + ); + } + + void shuffleAlbumToQueue() { + // linear order is used in this case since we don't want to affect the rest of the queue + List clonedItems = List.from(items); + clonedItems.shuffle(); + queueService.addToQueue( + items: clonedItems, + source: QueueItemSource( + type: isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: parentItem.name ?? + AppLocalizations.of(context)!.placeholderSource), + id: parentItem.id, + item: parentItem, + )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.confirmShuffleToQueue), + ), + ); + } return FlexibleSpaceBar( background: SafeArea( @@ -37,7 +217,7 @@ class AlbumScreenContentFlexibleSpaceBar extends StatelessWidget { children: [ SizedBox( height: 125, - child: AlbumImage(item: album), + child: AlbumImage(item: parentItem), ), const Padding( padding: EdgeInsets.symmetric(horizontal: 4), @@ -45,7 +225,7 @@ class AlbumScreenContentFlexibleSpaceBar extends StatelessWidget { Expanded( flex: 2, child: ItemInfo( - item: album, + item: parentItem, itemSongs: items.length, ), ) @@ -53,33 +233,101 @@ class AlbumScreenContentFlexibleSpaceBar extends StatelessWidget { ), Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: Row(children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => - audioServiceHelper.replaceQueueWithItem( - itemList: items, + child: Column( + children: [ + Row(children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => playAlbum(), + icon: const Icon(Icons.play_arrow), + label: Text( + AppLocalizations.of(context)!.playButtonLabel), + ), ), - icon: const Icon(Icons.play_arrow), - label: - Text(AppLocalizations.of(context)!.playButtonLabel), - ), - ), - const Padding(padding: EdgeInsets.symmetric(horizontal: 8)), - Expanded( - child: ElevatedButton.icon( - onPressed: () => - audioServiceHelper.replaceQueueWithItem( - itemList: items, - shuffle: true, - initialIndex: Random().nextInt(items.length), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8)), + Expanded( + child: ElevatedButton.icon( + onPressed: () => shuffleAlbum(), + icon: const Icon(Icons.shuffle), + label: Text(AppLocalizations.of(context)! + .shuffleButtonLabel), + ), ), - icon: const Icon(Icons.shuffle), - label: Text( - AppLocalizations.of(context)!.shuffleButtonLabel), - ), - ), - ]), + ]), + Row(children: [ + Expanded( + child: ElevatedButton.icon( + style: const ButtonStyle( + visualDensity: VisualDensity.compact), + onPressed: () => addAlbumNext(), + icon: const Icon(Icons.hourglass_bottom), + label: Text(AppLocalizations.of(context)!.playNext), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8)), + Expanded( + child: ElevatedButton.icon( + style: const ButtonStyle( + visualDensity: VisualDensity.compact), + onPressed: () => shuffleAlbumNext(), + icon: const Icon(Icons.hourglass_bottom), + label: + Text(AppLocalizations.of(context)!.shuffleNext), + ), + ), + ]), + Row(children: [ + Expanded( + child: ElevatedButton.icon( + style: const ButtonStyle( + visualDensity: VisualDensity.compact), + onPressed: () => addAlbumToNextUp(), + icon: const Icon(Icons.hourglass_top), + label: + Text(AppLocalizations.of(context)!.addToNextUp), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8)), + Expanded( + child: ElevatedButton.icon( + style: const ButtonStyle( + visualDensity: VisualDensity.compact), + onPressed: () => shuffleAlbumToNextUp(), + icon: const Icon(Icons.hourglass_top), + label: Text( + AppLocalizations.of(context)!.shuffleToNextUp), + ), + ), + ]), + Row(children: [ + Expanded( + child: ElevatedButton.icon( + style: const ButtonStyle( + visualDensity: VisualDensity.compact), + onPressed: () => addAlbumToQueue(), + icon: const Icon(Icons.queue_music), + label: + Text(AppLocalizations.of(context)!.addToQueue), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8)), + Expanded( + child: ElevatedButton.icon( + style: const ButtonStyle( + visualDensity: VisualDensity.compact), + onPressed: () => shuffleAlbumToQueue(), + icon: const Icon(Icons.queue_music), + label: Text( + AppLocalizations.of(context)!.shuffleToQueue), + ), + ), + ]), + ], + ), ) ], ), diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index 610fdf7f6..cfd2655d0 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -1,6 +1,9 @@ import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:get_it/get_it.dart'; import '../../models/jellyfin_models.dart'; @@ -20,7 +23,8 @@ import 'downloaded_indicator.dart'; enum SongListTileMenuItems { addToQueue, - replaceQueueWithItem, + playNext, + addToNextUp, addToPlaylist, removeFromPlaylist, instantMix, @@ -44,6 +48,7 @@ class SongListTile extends StatefulWidget { /// song in an album. this.index, this.parentId, + this.parentName, this.isSong = false, this.showArtists = true, this.onDelete, @@ -58,6 +63,7 @@ class SongListTile extends StatefulWidget { final int? index; final bool isSong; final String? parentId; + final String? parentName; final bool showArtists; final VoidCallback? onDelete; final bool isInPlaylist; @@ -68,6 +74,7 @@ class SongListTile extends StatefulWidget { class _SongListTileState extends State { final _audioServiceHelper = GetIt.instance(); + final _queueService = GetIt.instance(); final _audioHandler = GetIt.instance(); final _jellyfinApiHelper = GetIt.instance(); @@ -178,10 +185,29 @@ class _SongListTileState extends State { onlyIfFav: true, ), onTap: () { - _audioServiceHelper.replaceQueueWithItem( - itemList: widget.children ?? [widget.item], - initialIndex: widget.index ?? 0, - ); + if (widget.children != null) { + // start linear playback of album from the given index + _queueService.startPlayback( + items: widget.children!, + source: QueueItemSource( + type: widget.isInPlaylist + ? QueueItemSourceType.playlist + : QueueItemSourceType.album, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: (widget.isInPlaylist + ? widget.parentName + : widget.item.album) ?? + AppLocalizations.of(context)!.placeholderSource), + id: widget.parentId ?? "", + item: widget.item, + ), + order: FinampPlaybackOrder.linear, + startingIndex: widget.index ?? 0, + ); + } else { + _audioServiceHelper.startInstantMixForItem(widget.item); + } }, ); @@ -222,11 +248,19 @@ class _SongListTileState extends State { title: Text(AppLocalizations.of(context)!.addToQueue), ), ), + if (_queueService.getQueue().nextUp.isNotEmpty) + PopupMenuItem( + value: SongListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(TablerIcons.hourglass_low), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), PopupMenuItem( - value: SongListTileMenuItems.replaceQueueWithItem, + value: SongListTileMenuItems.addToNextUp, child: ListTile( - leading: const Icon(Icons.play_circle), - title: Text(AppLocalizations.of(context)!.replaceQueue), + leading: const Icon(TablerIcons.hourglass_high), + title: Text(AppLocalizations.of(context)!.addToNextUp), ), ), widget.isInPlaylist @@ -291,7 +325,14 @@ class _SongListTileState extends State { switch (selection) { case SongListTileMenuItems.addToQueue: - await _audioServiceHelper.addQueueItem(widget.item); + await _queueService.addToQueue( + items: [widget.item], + source: QueueItemSource( + type: QueueItemSourceType.unknown, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: AppLocalizations.of(context)!.queue), + id: widget.parentId ?? "unknown")); if (!mounted) return; @@ -300,14 +341,25 @@ class _SongListTileState extends State { )); break; - case SongListTileMenuItems.replaceQueueWithItem: - await _audioServiceHelper - .replaceQueueWithItem(itemList: [widget.item]); + case SongListTileMenuItems.playNext: + await _queueService.addNext(items: [widget.item]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.confirmPlayNext("track")), + )); + break; + + case SongListTileMenuItems.addToNextUp: + await _queueService.addToNextUp(items: [widget.item]); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.queueReplaced), + content: Text( + AppLocalizations.of(context)!.confirmAddToNextUp("track")), )); break; @@ -418,7 +470,15 @@ class _SongListTileState extends State { ), ), confirmDismiss: (direction) async { - await _audioServiceHelper.addQueueItem(widget.item); + await _queueService.addToQueue( + items: [widget.item], + source: QueueItemSource( + type: QueueItemSourceType.unknown, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: + AppLocalizations.of(context)!.queue), + id: widget.parentId!)); if (!mounted) return false; diff --git a/lib/components/MusicScreen/album_item.dart b/lib/components/MusicScreen/album_item.dart index b98843017..7a0c79fd7 100644 --- a/lib/components/MusicScreen/album_item.dart +++ b/lib/components/MusicScreen/album_item.dart @@ -1,5 +1,7 @@ import 'package:finamp/components/MusicScreen/album_item_list_tile.dart'; +import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; @@ -16,6 +18,12 @@ enum _AlbumListTileMenuItems { removeFavourite, addToMixList, removeFromMixList, + playNext, + addToNextUp, + shuffleNext, + shuffleToNextUp, + addToQueue, + shuffleToQueue, } /// This widget is kind of a shell around AlbumItemCard and AlbumItemListTile. @@ -26,6 +34,7 @@ class AlbumItem extends StatefulWidget { const AlbumItem({ Key? key, required this.album, + required this.isPlaylist, this.parentType, this.onTap, this.isGrid = false, @@ -40,6 +49,9 @@ class AlbumItem extends StatefulWidget { /// like artists. final String? parentType; + /// Used to differentiate between albums and playlists, since they use the same internal logic and widgets + final bool isPlaylist; + /// A custom onTap can be provided to override the default value, which is to /// open the item's album/artist screen. final void Function()? onTap; @@ -60,7 +72,11 @@ class AlbumItem extends StatefulWidget { class _AlbumItemState extends State { late BaseItemDto mutableAlbum; + QueueService get _queueService => GetIt.instance(); + late Function() onTap; + late AppLocalizations local; + late ScaffoldMessengerState messenger; @override void initState() { @@ -83,6 +99,9 @@ class _AlbumItemState extends State { @override Widget build(BuildContext context) { + local = AppLocalizations.of(context)!; + messenger = ScaffoldMessenger.of(context); + final screenSize = MediaQuery.of(context).size; return Padding( @@ -115,33 +134,75 @@ class _AlbumItemState extends State { value: _AlbumListTileMenuItems.removeFavourite, child: ListTile( leading: const Icon(Icons.favorite_border), - title: - Text(AppLocalizations.of(context)!.removeFavourite), + title: Text(local.removeFavourite), ), ) : PopupMenuItem<_AlbumListTileMenuItems>( value: _AlbumListTileMenuItems.addFavourite, child: ListTile( leading: const Icon(Icons.favorite), - title: Text(AppLocalizations.of(context)!.addFavourite), + title: Text(local.addFavourite), ), ), - jellyfinApiHelper.selectedMixAlbumIds.contains(mutableAlbum.id) + jellyfinApiHelper.selectedMixAlbums.contains(mutableAlbum.id) ? PopupMenuItem<_AlbumListTileMenuItems>( value: _AlbumListTileMenuItems.removeFromMixList, child: ListTile( leading: const Icon(Icons.explore_off), - title: - Text(AppLocalizations.of(context)!.removeFromMix), + title: Text(local.removeFromMix), ), ) : PopupMenuItem<_AlbumListTileMenuItems>( value: _AlbumListTileMenuItems.addToMixList, child: ListTile( leading: const Icon(Icons.explore), - title: Text(AppLocalizations.of(context)!.addToMix), + title: Text(local.addToMix), ), ), + if (_queueService.getQueue().nextUp.isNotEmpty) + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(Icons.hourglass_bottom), + title: Text(local.playNext), + ), + ), + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.addToNextUp, + child: ListTile( + leading: const Icon(Icons.hourglass_top), + title: Text(local.addToNextUp), + ), + ), + if (_queueService.getQueue().nextUp.isNotEmpty) + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.shuffleNext, + child: ListTile( + leading: const Icon(Icons.hourglass_bottom), + title: Text(local.shuffleNext), + ), + ), + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.shuffleToNextUp, + child: ListTile( + leading: const Icon(Icons.hourglass_top), + title: Text(local.shuffleToNextUp), + ), + ), + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.addToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(local.addToQueue), + ), + ), + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.shuffleToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(local.shuffleToQueue), + ), + ), ], ); @@ -159,7 +220,7 @@ class _AlbumItemState extends State { mutableAlbum.userData = newUserData; }); - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar(content: Text("Favourite added."))); } catch (e) { errorSnackbar(e, context); @@ -175,7 +236,7 @@ class _AlbumItemState extends State { setState(() { mutableAlbum.userData = newUserData; }); - ScaffoldMessenger.of(context).showSnackBar( + messenger.showSnackBar( const SnackBar(content: Text("Favourite removed."))); } catch (e) { errorSnackbar(e, context); @@ -191,7 +252,283 @@ class _AlbumItemState extends State { break; case _AlbumListTileMenuItems.removeFromMixList: try { - jellyfinApiHelper.removeAlbumFromBuilderList(mutableAlbum); + jellyfinApiHelper.removeAlbumFromMixBuilderList(mutableAlbum); + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.playNext: + try { + List? albumTracks = + await jellyfinApiHelper.getItems( + parentItem: mutableAlbum, + isGenres: false, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + messenger.showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.isPlaylist ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addNext( + items: albumTracks, + source: QueueItemSource( + type: widget.isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: + mutableAlbum.name ?? local.placeholderSource), + id: mutableAlbum.id, + item: mutableAlbum, + )); + + messenger.showSnackBar( + SnackBar( + content: Text(local.confirmPlayNext( + widget.isPlaylist ? "playlist" : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.addToNextUp: + try { + List? albumTracks = + await jellyfinApiHelper.getItems( + parentItem: mutableAlbum, + isGenres: false, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + messenger.showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.isPlaylist ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addToNextUp( + items: albumTracks, + source: QueueItemSource( + type: widget.isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: + mutableAlbum.name ?? local.placeholderSource), + id: mutableAlbum.id, + item: mutableAlbum, + )); + + messenger.showSnackBar( + SnackBar( + content: Text(local.confirmAddToNextUp( + widget.isPlaylist ? "playlist" : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.shuffleNext: + try { + List? albumTracks = + await jellyfinApiHelper.getItems( + parentItem: mutableAlbum, + isGenres: false, + sortBy: "Random", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + messenger.showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.isPlaylist ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addNext( + items: albumTracks, + source: QueueItemSource( + type: widget.isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: + mutableAlbum.name ?? local.placeholderSource), + id: mutableAlbum.id, + item: mutableAlbum, + )); + + messenger.showSnackBar( + SnackBar( + content: Text(local.confirmPlayNext( + widget.isPlaylist ? "playlist" : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.shuffleToNextUp: + try { + List? albumTracks = + await jellyfinApiHelper.getItems( + parentItem: mutableAlbum, + isGenres: false, + sortBy: + "Random", //TODO this isn't working anymore with Jellyfin 10.9 (unstable) + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + messenger.showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.isPlaylist ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addToNextUp( + items: albumTracks, + source: QueueItemSource( + type: widget.isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: + mutableAlbum.name ?? local.placeholderSource), + id: mutableAlbum.id, + item: mutableAlbum, + )); + + messenger.showSnackBar( + SnackBar( + content: Text(local.confirmShuffleToNextUp), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.addToQueue: + try { + List? albumTracks = + await jellyfinApiHelper.getItems( + parentItem: mutableAlbum, + isGenres: false, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + messenger.showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.isPlaylist ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addToQueue( + items: albumTracks, + source: QueueItemSource( + type: widget.isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: + mutableAlbum.name ?? local.placeholderSource), + id: mutableAlbum.id, + item: mutableAlbum, + )); + + messenger.showSnackBar( + SnackBar( + content: Text(local.confirmAddToQueue( + widget.isPlaylist ? "playlist" : "album")), + ), + ); + + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.shuffleToQueue: + try { + List? albumTracks = + await jellyfinApiHelper.getItems( + parentItem: mutableAlbum, + isGenres: false, + sortBy: "Random", + includeItemTypes: "Audio", + ); + + if (albumTracks == null) { + messenger.showSnackBar( + SnackBar( + content: Text( + "Couldn't load ${widget.isPlaylist ? "playlist" : "album"}."), + ), + ); + return; + } + + _queueService.addToQueue( + items: albumTracks, + source: QueueItemSource( + type: widget.isPlaylist + ? QueueItemSourceType.nextUpPlaylist + : QueueItemSourceType.nextUpAlbum, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: + mutableAlbum.name ?? local.placeholderSource), + id: mutableAlbum.id, + item: mutableAlbum, + )); + + messenger.showSnackBar( + SnackBar( + content: Text(local.confirmAddToQueue( + widget.isPlaylist ? "playlist" : "album")), + ), + ); + setState(() {}); } catch (e) { errorSnackbar(e, context); diff --git a/lib/components/MusicScreen/album_item_list_tile.dart b/lib/components/MusicScreen/album_item_list_tile.dart index 68fe579ac..304a6db3c 100644 --- a/lib/components/MusicScreen/album_item_list_tile.dart +++ b/lib/components/MusicScreen/album_item_list_tile.dart @@ -36,7 +36,7 @@ class AlbumItemListTile extends StatelessWidget { overflow: TextOverflow.ellipsis, ), subtitle: subtitle == null ? null : Text(subtitle), - trailing: jellyfinApiHelper.selectedMixAlbumIds.contains(item.id) + trailing: jellyfinApiHelper.selectedMixAlbums.contains(item.id) ? const Icon(Icons.explore) : null, ); diff --git a/lib/components/MusicScreen/artist_item_list_tile.dart b/lib/components/MusicScreen/artist_item_list_tile.dart index 6acf943a1..69e2ced84 100644 --- a/lib/components/MusicScreen/artist_item_list_tile.dart +++ b/lib/components/MusicScreen/artist_item_list_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../models/jellyfin_models.dart'; import '../../screens/artist_screen.dart'; @@ -50,10 +51,9 @@ class _ArtistListTileState extends State { overflow: TextOverflow.ellipsis, ), subtitle: null, - trailing: - _jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id) - ? const Icon(Icons.explore) - : null, + trailing: _jellyfinApiHelper.selectedMixArtists.contains(mutableItem.id) + ? const Icon(Icons.explore) + : null, ); return GestureDetector( @@ -72,27 +72,29 @@ class _ArtistListTileState extends State { ), items: [ mutableItem.userData!.isFavorite - ? const PopupMenuItem( + ? PopupMenuItem( value: ArtistListTileMenuItems.removeFromFavourite, child: ListTile( - leading: Icon(Icons.favorite_border), - title: Text("Remove Favourite"), + leading: const Icon(Icons.favorite_border), + title: + Text(AppLocalizations.of(context)!.removeFavourite), ), ) - : const PopupMenuItem( + : PopupMenuItem( value: ArtistListTileMenuItems.addToFavourite, child: ListTile( - leading: Icon(Icons.favorite), - title: Text("Add Favourite"), + leading: const Icon(Icons.favorite), + title: Text(AppLocalizations.of(context)!.addFavourite), ), ), - _jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id) + _jellyfinApiHelper.selectedMixArtists.contains(mutableItem.id) ? PopupMenuItem( enabled: !isOffline, value: ArtistListTileMenuItems.removeFromMixList, child: ListTile( leading: const Icon(Icons.explore_off), - title: const Text("Remove From Mix"), + title: + Text(AppLocalizations.of(context)!.removeFromMix), enabled: isOffline ? false : true, ), ) @@ -101,7 +103,7 @@ class _ArtistListTileState extends State { enabled: !isOffline, child: ListTile( leading: const Icon(Icons.explore), - title: const Text("Add To Mix"), + title: Text(AppLocalizations.of(context)!.addToMix), enabled: !isOffline, ), ), @@ -153,7 +155,7 @@ class _ArtistListTileState extends State { break; case ArtistListTileMenuItems.removeFromMixList: try { - _jellyfinApiHelper.removeArtistFromBuilderList(mutableItem); + _jellyfinApiHelper.removeArtistFromMixBuilderList(mutableItem); setState(() {}); } catch (e) { errorSnackbar(e, context); diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart index b4ccab862..c6639542a 100644 --- a/lib/components/MusicScreen/music_screen_tab_view.dart +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -306,6 +306,8 @@ class _MusicScreenTabViewState extends State return AlbumItem( album: offlineSortedItems![index], parentType: _getParentType(), + isPlaylist: widget.tabContentType == + TabContentType.playlists, ); } }, @@ -334,6 +336,8 @@ class _MusicScreenTabViewState extends State return AlbumItem( album: offlineSortedItems![index], parentType: _getParentType(), + isPlaylist: widget.tabContentType == + TabContentType.playlists, isGrid: true, gridAddSettingsListener: false, ); @@ -383,6 +387,8 @@ class _MusicScreenTabViewState extends State return AlbumItem( album: item, parentType: _getParentType(), + isPlaylist: widget.tabContentType == + TabContentType.playlists, ); } }, @@ -414,6 +420,8 @@ class _MusicScreenTabViewState extends State return AlbumItem( album: item, parentType: _getParentType(), + isPlaylist: widget.tabContentType == + TabContentType.playlists, isGrid: true, gridAddSettingsListener: false, ); diff --git a/lib/components/PlaybackHistoryScreen/playback_history_list.dart b/lib/components/PlaybackHistoryScreen/playback_history_list.dart new file mode 100644 index 000000000..3d1e980a7 --- /dev/null +++ b/lib/components/PlaybackHistoryScreen/playback_history_list.dart @@ -0,0 +1,124 @@ +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/services/audio_service_helper.dart'; +import 'package:finamp/services/locale_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart'; + +import '../../services/playback_history_service.dart'; +import '../../models/jellyfin_models.dart' as jellyfin_models; +import 'playback_history_list_tile.dart'; + +class PlaybackHistoryList extends StatelessWidget { + const PlaybackHistoryList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final playbackHistoryService = GetIt.instance(); + final audioServiceHelper = GetIt.instance(); + + List? history; + List>> groupedHistory; + + return StreamBuilder>( + stream: playbackHistoryService.historyStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + history = snapshot.data; + // groupedHistory = playbackHistoryService.getHistoryGroupedByDate(); + // groupedHistory = playbackHistoryService.getHistoryGroupedByHour(); + groupedHistory = + playbackHistoryService.getHistoryGroupedDynamically(); + + print(groupedHistory); + + return CustomScrollView( + // use nested SliverList.builder()s to show history items grouped by date + slivers: groupedHistory.map((group) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final actualIndex = group.value.length - index - 1; + + final historyItem = Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: PlaybackHistoryListTile( + actualIndex: actualIndex, + item: group.value[actualIndex], + audioServiceHelper: audioServiceHelper, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)! + .startingInstantMix), + )); + + audioServiceHelper + .startInstantMixForItem( + jellyfin_models.BaseItemDto.fromJson(group + .value[actualIndex] + .item + .item + .extras?["itemJson"])) + .catchError((e) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)! + .anErrorHasOccured), + )); + }); + }, + ), + ); + + final now = DateTime.now(); + final String localeString = (LocaleHelper.locale != null) + ? ((LocaleHelper.locale?.countryCode != null) + ? "${LocaleHelper.locale?.languageCode.toLowerCase()}_${LocaleHelper.locale?.countryCode?.toUpperCase()}" + : LocaleHelper.locale.toString()) + : "en_US"; + + return index == 0 + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 16.0, top: 8.0, bottom: 4.0), + child: Text( + (group.key.year == now.year && + group.key.month == now.month && + group.key.day == now.day) + ? (group.key.hour == now.hour + ? DateFormat.jm(localeString) + .format(group.key) + : DateFormat.j(localeString) + .format(group.key)) + : DateFormat.MMMMd(localeString) + .format(group.key), + style: const TextStyle( + fontSize: 16.0, + ), + ), + ), + historyItem, + ], + ) + : historyItem; + }, + childCount: group.value.length, + ), + ); + }).toList(), + ); + } else { + return const Center( + child: CircularProgressIndicator(), + ); + } + }); + } +} diff --git a/lib/components/PlaybackHistoryScreen/playback_history_list_tile.dart b/lib/components/PlaybackHistoryScreen/playback_history_list_tile.dart new file mode 100644 index 000000000..76e854d78 --- /dev/null +++ b/lib/components/PlaybackHistoryScreen/playback_history_list_tile.dart @@ -0,0 +1,406 @@ +import 'package:finamp/components/favourite_button.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/services/audio_service_helper.dart'; +import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart' as jellyfin_models; +import '../album_image.dart'; +import '../../services/process_artist.dart'; + +import 'package:finamp/components/AlbumScreen/song_list_tile.dart'; +import 'package:finamp/components/error_snackbar.dart'; +import 'package:finamp/screens/add_to_playlist_screen.dart'; +import 'package:finamp/screens/album_screen.dart'; +import 'package:finamp/services/downloads_helper.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:flutter/material.dart' hide ReorderableList; + +class PlaybackHistoryListTile extends StatefulWidget { + PlaybackHistoryListTile({ + super.key, + required this.actualIndex, + required this.item, + required this.audioServiceHelper, + required this.onTap, + }); + + final int actualIndex; + final FinampHistoryItem item; + final AudioServiceHelper audioServiceHelper; + late void Function() onTap; + + final _queueService = GetIt.instance(); + final _audioServiceHelper = GetIt.instance(); + final _jellyfinApiHelper = GetIt.instance(); + + @override + State createState() => + _PlaybackHistoryListTileState(); +} + +class _PlaybackHistoryListTileState extends State { + @override + Widget build(BuildContext context) { + final baseItem = jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]); + + return GestureDetector( + onLongPressStart: (details) => showSongMenu(details), + child: Card( + margin: EdgeInsets.all(0.0), + elevation: 0, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + child: ListTile( + visualDensity: VisualDensity.standard, + minVerticalPadding: 0.0, + horizontalTitleGap: 10.0, + contentPadding: const EdgeInsets.only(right: 4.0), + leading: AlbumImage( + item: widget.item.item.item.extras?["itemJson"] == null + ? null + : baseItem, + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(0.0), + child: Text( + widget.item.item.item.title ?? + AppLocalizations.of(context)!.unknownName, + overflow: TextOverflow.ellipsis, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Text( + processArtist(widget.item.item.item.artist, context), + style: TextStyle( + color: Theme.of(context).textTheme.bodyMedium!.color!, + fontSize: 13, + fontFamily: 'Lexend Deca', + fontWeight: FontWeight.w300, + overflow: TextOverflow.ellipsis), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + // subtitle: Container( + // alignment: Alignment.centerLeft, + // height: 40.5, // has to be above a certain value to get rid of vertical padding + // child: Padding( + // padding: const EdgeInsets.only(bottom: 2.0), + // child: Text( + // processArtist(widget.item.item.item.artist, context), + // style: const TextStyle( + // color: Colors.white70, + // fontSize: 13, + // fontFamily: 'Lexend Deca', + // fontWeight: FontWeight.w300, + // overflow: TextOverflow.ellipsis), + // overflow: TextOverflow.ellipsis, + // ), + // ), + // ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "${widget.item.item.item.duration?.inMinutes.toString()}:${((widget.item.item.item.duration?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}", + textAlign: TextAlign.end, + style: TextStyle( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + FavoriteButton( + item: baseItem, + onToggle: (isFavorite) => setState(() { + if (baseItem.userData != null) { + baseItem.userData!.isFavorite = isFavorite; + widget.item.item.item.extras?["itemJson"] = + baseItem.toJson(); + } + }), + ) + ], + ), + onTap: widget.onTap, + ))); + } + + void showSongMenu(LongPressStartDetails? details) async { + final canGoToAlbum = _isAlbumDownloadedIfOffline( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .parentId); + + // Some options are disabled in offline mode + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + final screenSize = MediaQuery.of(context).size; + + Feedback.forLongPress(context); + + final selection = await showMenu( + context: context, + position: details != null + ? RelativeRect.fromLTRB( + details.globalPosition.dx, + details.globalPosition.dy, + screenSize.width - details.globalPosition.dx, + screenSize.height - details.globalPosition.dy, + ) + : RelativeRect.fromLTRB(MediaQuery.of(context).size.width - 50.0, + MediaQuery.of(context).size.height - 50.0, 0.0, 0.0), + items: [ + 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(TablerIcons.hourglass_low), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), + PopupMenuItem( + value: SongListTileMenuItems.addToNextUp, + child: ListTile( + leading: const Icon(TablerIcons.hourglass_high), + title: Text(AppLocalizations.of(context)!.addToNextUp), + ), + ), + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.addToPlaylist, + child: ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(AppLocalizations.of(context)!.addToPlaylistTitle), + enabled: !isOffline, + ), + ), + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.instantMix, + child: ListTile( + leading: const Icon(Icons.explore), + title: Text(AppLocalizations.of(context)!.instantMix), + enabled: !isOffline, + ), + ), + PopupMenuItem( + enabled: canGoToAlbum, + value: SongListTileMenuItems.goToAlbum, + child: ListTile( + leading: const Icon(Icons.album), + title: Text(AppLocalizations.of(context)!.goToAlbum), + enabled: canGoToAlbum, + ), + ), + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .userData! + .isFavorite + ? PopupMenuItem( + value: SongListTileMenuItems.removeFavourite, + child: ListTile( + leading: const Icon(Icons.favorite_border), + title: Text(AppLocalizations.of(context)!.removeFavourite), + ), + ) + : PopupMenuItem( + value: SongListTileMenuItems.addFavourite, + child: ListTile( + leading: const Icon(Icons.favorite), + title: Text(AppLocalizations.of(context)!.addFavourite), + ), + ), + ], + ); + + if (!mounted) return; + + switch (selection) { + case SongListTileMenuItems.addToQueue: + await widget._queueService.addToQueue( + items: [ + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + ], + source: QueueItemSource( + type: QueueItemSourceType.unknown, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: AppLocalizations.of(context)!.queue), + id: widget.item.item.source.id)); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.addedToQueue), + )); + break; + + case SongListTileMenuItems.playNext: + await widget._queueService.addNext(items: [ + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + ]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.confirmPlayNext("track")), + )); + break; + + case SongListTileMenuItems.addToNextUp: + await widget._queueService.addToNextUp(items: [ + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + ]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.confirmAddToNextUp("track")), + )); + break; + + case SongListTileMenuItems.addToPlaylist: + Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName, + arguments: jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .id); + break; + + case SongListTileMenuItems.instantMix: + await widget._audioServiceHelper.startInstantMixForItem( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"])); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.startingInstantMix), + )); + break; + case SongListTileMenuItems.goToAlbum: + late jellyfin_models.BaseItemDto album; + if (FinampSettingsHelper.finampSettings.isOffline) { + // If offline, load the album's BaseItemDto from DownloadHelper. + final downloadsHelper = GetIt.instance(); + + // downloadedParent won't be null here since the menu item already + // checks if the DownloadedParent exists. + album = downloadsHelper + .getDownloadedParent(jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .parentId!)! + .item; + } else { + // If online, get the album's BaseItemDto from the server. + try { + album = await widget._jellyfinApiHelper.getItemById( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .parentId!); + } catch (e) { + errorSnackbar(e, context); + break; + } + } + + if (!mounted) return; + + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: album); + break; + case SongListTileMenuItems.addFavourite: + case SongListTileMenuItems.removeFavourite: + await setFavourite(); + break; + case null: + break; + } + } + + Future setFavourite() async { + try { + // We switch the widget state before actually doing the request to + // make the app feel faster (without, there is a delay from the + // user adding the favourite and the icon showing) + setState(() { + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .userData! + .isFavorite = !jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .userData! + .isFavorite; + }); + + // Since we flipped the favourite state already, we can use the flipped + // state to decide which API call to make + final newUserData = jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .userData! + .isFavorite + ? await widget._jellyfinApiHelper.addFavourite( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .id) + : await widget._jellyfinApiHelper.removeFavourite( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .id); + + if (!mounted) return; + + setState(() { + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .userData = newUserData; + }); + } catch (e) { + setState(() { + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .userData! + .isFavorite = !jellyfin_models.BaseItemDto.fromJson( + widget.item.item.item.extras?["itemJson"]) + .userData! + .isFavorite; + }); + errorSnackbar(e, context); + } + } +} + +/// If offline, check if an album is downloaded. Always returns true if online. +/// Returns false if albumId is null. +bool _isAlbumDownloadedIfOffline(String? albumId) { + if (albumId == null) { + return false; + } else if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsHelper = GetIt.instance(); + return downloadsHelper.isAlbumDownloaded(albumId); + } else { + return true; + } +} diff --git a/lib/components/PlayerScreen/album_chip.dart b/lib/components/PlayerScreen/album_chip.dart index f70925fd3..d3bea7671 100644 --- a/lib/components/PlayerScreen/album_chip.dart +++ b/lib/components/PlayerScreen/album_chip.dart @@ -13,17 +13,19 @@ class AlbumChip extends StatelessWidget { const AlbumChip({ Key? key, this.item, + this.color, }) : super(key: key); final BaseItemDto? item; + final Color? color; @override Widget build(BuildContext context) { if (item == null) return const _EmptyAlbumChip(); return Container( - constraints: const BoxConstraints(minWidth: 10, maxWidth: 200), - child: _AlbumChipContent(item: item!)); + constraints: const BoxConstraints(minWidth: 10), + child: _AlbumChipContent(item: item!, color: color)); } } @@ -46,15 +48,18 @@ class _AlbumChipContent extends StatelessWidget { const _AlbumChipContent({ Key? key, required this.item, + required this.color, }) : super(key: key); final BaseItemDto item; + final Color? color; @override Widget build(BuildContext context) { final jellyfinApiHelper = GetIt.instance(); return Material( + color: color ?? Colors.white.withOpacity(0.1), borderRadius: _borderRadius, child: InkWell( borderRadius: _borderRadius, @@ -68,7 +73,7 @@ class _AlbumChipContent extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4), child: Text( item.album ?? AppLocalizations.of(context)!.noAlbum, - overflow: TextOverflow.fade, + overflow: TextOverflow.ellipsis, softWrap: false, ), ), diff --git a/lib/components/PlayerScreen/artist_chip.dart b/lib/components/PlayerScreen/artist_chip.dart index d128e7b8a..0a6504477 100644 --- a/lib/components/PlayerScreen/artist_chip.dart +++ b/lib/components/PlayerScreen/artist_chip.dart @@ -16,13 +16,52 @@ const _textStyle = TextStyle( overflow: TextOverflow.fade, ); +class ArtistChips extends StatelessWidget { + const ArtistChips({ + Key? key, + this.color, + this.baseItem, + }) : super(key: key); + + final BaseItemDto? baseItem; + final Color? color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: List.generate(baseItem?.artistItems?.length ?? 0, (index) { + final currentArtist = baseItem!.artistItems![index]; + + return ArtistChip( + color: color, + artist: BaseItemDto( + id: currentArtist.id, + name: currentArtist.name, + type: "MusicArtist", + ), + ); + }), + ), + ), + ); + } +} + class ArtistChip extends StatefulWidget { const ArtistChip({ Key? key, - this.item, + this.color, + this.artist, }) : super(key: key); - final BaseItemDto? item; + final BaseItemDto? artist; + final Color? color; @override State createState() => _ArtistChipState(); @@ -39,50 +78,27 @@ class _ArtistChipState extends State { void initState() { super.initState(); - if (widget.item != null) { - final albumArtistId = widget.item!.albumArtists?.first.id; - - if (albumArtistId != null) { - // This is a terrible hack but since offline artists aren't yet - // implemented it's kind of needed. When offline, we make a fake item - // with the required amount of data to show an artist chip. - _artistChipFuture = FinampSettingsHelper.finampSettings.isOffline - ? Future.sync( - () => BaseItemDto( - id: widget.item!.id, - name: widget.item!.albumArtist, - type: "MusicArtist", - ), - ) - : _jellyfinApiHelper.getItemById(albumArtistId); - } + if (widget.artist != null) { + final albumArtistId = widget.artist!.id; + + // This is a terrible hack but since offline artists aren't yet + // implemented it's kind of needed. When offline, we make a fake item + // with the required amount of data to show an artist chip. + _artistChipFuture = FinampSettingsHelper.finampSettings.isOffline + ? Future.sync(() => widget.artist!) + : _jellyfinApiHelper.getItemById(albumArtistId); } } @override Widget build(BuildContext context) { - if (_artistChipFuture == null) return const _EmptyArtistChip(); - return FutureBuilder( - future: _artistChipFuture, - builder: (context, snapshot) => - _ArtistChipContent(item: snapshot.data ?? widget.item!), - ); - } -} - -class _EmptyArtistChip extends StatelessWidget { - const _EmptyArtistChip({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return const SizedBox( - height: _height, - width: 72, - child: Material( - borderRadius: _borderRadius, - ), - ); + future: _artistChipFuture, + builder: (context, snapshot) { + final color = widget.color ?? _defaultColour; + return _ArtistChipContent( + item: snapshot.data ?? widget.artist!, color: color); + }); } } @@ -90,19 +106,23 @@ class _ArtistChipContent extends StatelessWidget { const _ArtistChipContent({ Key? key, required this.item, + required this.color, }) : super(key: key); final BaseItemDto item; + final Color color; @override Widget build(BuildContext context) { // We do this so that we can pass the song item here to show an actual value // instead of empty - final name = item.isArtist ? item.name : item.albumArtist; + final name = + item.isArtist ? item.name : (item.artists?.first ?? item.albumArtist); return SizedBox( height: 24, child: Material( + color: color, borderRadius: _borderRadius, child: InkWell( // Offline artists aren't implemented and we shouldn't click through @@ -124,13 +144,16 @@ class _ArtistChipContent extends StatelessWidget { ), ), Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - name ?? AppLocalizations.of(context)!.unknownArtist, - style: _textStyle, - softWrap: false, - overflow: TextOverflow.fade, + child: Container( + constraints: const BoxConstraints(maxWidth: 220), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + name ?? AppLocalizations.of(context)!.unknownArtist, + style: _textStyle, + softWrap: false, + overflow: TextOverflow.ellipsis, + ), ), ), ) diff --git a/lib/components/PlayerScreen/control_area.dart b/lib/components/PlayerScreen/control_area.dart index a57840da2..09d7fce18 100644 --- a/lib/components/PlayerScreen/control_area.dart +++ b/lib/components/PlayerScreen/control_area.dart @@ -9,7 +9,7 @@ class ControlArea extends StatelessWidget { @override Widget build(BuildContext context) { return const Padding( - padding: EdgeInsets.symmetric(horizontal: 20), + padding: EdgeInsets.only(left: 20.0, right: 20.0, bottom: 8.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -17,7 +17,7 @@ class ControlArea extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 16), child: ProgressSlider(), ), - Padding(padding: EdgeInsets.symmetric(vertical: 4)), + Padding(padding: EdgeInsets.symmetric(vertical: 2)), PlayerButtons(), ], ), diff --git a/lib/components/PlayerScreen/finamp_back_button_icon.dart b/lib/components/PlayerScreen/finamp_back_button_icon.dart index 942b12ddf..60d370769 100644 --- a/lib/components/PlayerScreen/finamp_back_button_icon.dart +++ b/lib/components/PlayerScreen/finamp_back_button_icon.dart @@ -12,12 +12,15 @@ class FinampBackButtonIcon extends StatelessWidget { Widget build(BuildContext context) { return CustomPaint( size: Size(size, size), - painter: RPSCustomPainter(), + painter: RPSCustomPainter(context), ); } } class RPSCustomPainter extends CustomPainter { + BuildContext context; + RPSCustomPainter(this.context); + @override void paint(Canvas canvas, Size size) { Path path_0 = Path(); @@ -28,7 +31,7 @@ class RPSCustomPainter extends CustomPainter { Paint paint0Stroke = Paint() ..style = PaintingStyle.stroke ..strokeWidth = size.width * 0.08333333; - paint0Stroke.color = Colors.white.withOpacity(1.0); + paint0Stroke.color = Theme.of(context).iconTheme.color ?? Colors.white; paint0Stroke.strokeCap = StrokeCap.round; paint0Stroke.strokeJoin = StrokeJoin.round; canvas.drawPath(path_0, paint0Stroke); diff --git a/lib/components/PlayerScreen/player_buttons.dart b/lib/components/PlayerScreen/player_buttons.dart index 1c220f717..238c66573 100644 --- a/lib/components/PlayerScreen/player_buttons.dart +++ b/lib/components/PlayerScreen/player_buttons.dart @@ -1,4 +1,5 @@ import 'package:finamp/components/PlayerScreen/player_buttons_repeating.dart'; +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'; @@ -13,6 +14,7 @@ class PlayerButtons extends StatelessWidget { @override Widget build(BuildContext context) { final audioHandler = GetIt.instance(); + final queueService = GetIt.instance(); return StreamBuilder( stream: mediaStateStream, @@ -35,6 +37,7 @@ class PlayerButtons extends StatelessWidget { _RoundedIconButton( width: 75, height: 75, + borderRadius: BorderRadius.circular(24), onTap: playbackState != null ? () async { if (playbackState.playing) { diff --git a/lib/components/PlayerScreen/player_buttons_more.dart b/lib/components/PlayerScreen/player_buttons_more.dart index b9f2ecaaa..415e37af5 100644 --- a/lib/components/PlayerScreen/player_buttons_more.dart +++ b/lib/components/PlayerScreen/player_buttons_more.dart @@ -1,3 +1,5 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:finamp/components/PlayerScreen/sleep_timer_button.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:finamp/screens/add_to_playlist_screen.dart'; import 'package:finamp/services/music_player_background_task.dart'; @@ -6,7 +8,7 @@ import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; -enum PlayerButtonsMoreItems { shuffle, repeat, addToPlaylist } +enum PlayerButtonsMoreItems { shuffle, repeat, addToPlaylist, sleepTimer } class PlayerButtonsMore extends StatelessWidget { final audioHandler = GetIt.instance(); @@ -22,8 +24,9 @@ class PlayerButtonsMore extends StatelessWidget { Radius.circular(15), ), ), - icon: const Icon( + icon: Icon( TablerIcons.menu_2, + color: IconTheme.of(context).color!, ), itemBuilder: (BuildContext context) => >[ @@ -49,7 +52,11 @@ class PlayerButtonsMore extends StatelessWidget { title: Text(AppLocalizations.of(context)! .addToPlaylistTooltip)); } - })) + })), + const PopupMenuItem( + value: PlayerButtonsMoreItems.sleepTimer, + child: SleepTimerButton(), + ), ], ); } diff --git a/lib/components/PlayerScreen/player_buttons_repeating.dart b/lib/components/PlayerScreen/player_buttons_repeating.dart index a40a55b57..85d389da0 100644 --- a/lib/components/PlayerScreen/player_buttons_repeating.dart +++ b/lib/components/PlayerScreen/player_buttons_repeating.dart @@ -1,12 +1,16 @@ import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/services/media_state_stream.dart'; import 'package:finamp/services/music_player_background_task.dart'; +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:get_it/get_it.dart'; class PlayerButtonsRepeating extends StatelessWidget { final audioHandler = GetIt.instance(); + final queueService = GetIt.instance(); PlayerButtonsRepeating({ Key? key, @@ -17,40 +21,35 @@ class PlayerButtonsRepeating extends StatelessWidget { return StreamBuilder( stream: mediaStateStream, builder: (BuildContext context, AsyncSnapshot snapshot) { - final mediaState = snapshot.data; - final playbackState = mediaState?.playbackState; return IconButton( - onPressed: playbackState != null - ? () async { - // Cyles from none -> all -> one - if (playbackState!.repeatMode == - AudioServiceRepeatMode.none) { - await audioHandler - .setRepeatMode(AudioServiceRepeatMode.all); - } else if (playbackState!.repeatMode == - AudioServiceRepeatMode.all) { - await audioHandler - .setRepeatMode(AudioServiceRepeatMode.one); - } else { - await audioHandler - .setRepeatMode(AudioServiceRepeatMode.none); - } - } - : null, + 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; + } + }, icon: _getRepeatingIcon( - playbackState == null - ? AudioServiceRepeatMode.none - : playbackState!.repeatMode, + queueService.loopMode, Theme.of(context).colorScheme.secondary, )); }); } - Widget _getRepeatingIcon( - AudioServiceRepeatMode repeatMode, Color iconColour) { - if (repeatMode == AudioServiceRepeatMode.all) { + Widget _getRepeatingIcon(FinampLoopMode loopMode, Color iconColour) { + if (loopMode == FinampLoopMode.all) { return const Icon(TablerIcons.repeat); - } else if (repeatMode == AudioServiceRepeatMode.one) { + } else if (loopMode == FinampLoopMode.one) { return const Icon(TablerIcons.repeat_once); } else { return const Icon(TablerIcons.repeat_off); diff --git a/lib/components/PlayerScreen/player_buttons_shuffle.dart b/lib/components/PlayerScreen/player_buttons_shuffle.dart index bf957a069..afddb0f71 100644 --- a/lib/components/PlayerScreen/player_buttons_shuffle.dart +++ b/lib/components/PlayerScreen/player_buttons_shuffle.dart @@ -1,12 +1,15 @@ import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/services/media_state_stream.dart'; 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:get_it/get_it.dart'; class PlayerButtonsShuffle extends StatelessWidget { final audioHandler = GetIt.instance(); + final _queueService = GetIt.instance(); PlayerButtonsShuffle({Key? key}) : super(key: key); @@ -15,23 +18,15 @@ class PlayerButtonsShuffle extends StatelessWidget { return StreamBuilder( stream: mediaStateStream, builder: (BuildContext context, AsyncSnapshot snapshot) { - final mediaState = snapshot.data; - final playbackState = mediaState?.playbackState; return IconButton( - onPressed: playbackState != null - ? () async { - if (playbackState!.shuffleMode == - AudioServiceShuffleMode.all) { - await audioHandler - .setShuffleMode(AudioServiceShuffleMode.none); - } else { - await audioHandler - .setShuffleMode(AudioServiceShuffleMode.all); - } - } - : null, + onPressed: () async { + _queueService.playbackOrder = + _queueService.playbackOrder == FinampPlaybackOrder.shuffled + ? FinampPlaybackOrder.linear + : FinampPlaybackOrder.shuffled; + }, icon: Icon( - (playbackState?.shuffleMode == AudioServiceShuffleMode.all + (_queueService.playbackOrder == FinampPlaybackOrder.shuffled ? TablerIcons.arrows_shuffle : TablerIcons.arrows_right), ), diff --git a/lib/components/PlayerScreen/player_screen_appbar_title.dart b/lib/components/PlayerScreen/player_screen_appbar_title.dart new file mode 100644 index 000000000..5e14ffd36 --- /dev/null +++ b/lib/components/PlayerScreen/player_screen_appbar_title.dart @@ -0,0 +1,73 @@ +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'; +import 'package:get_it/get_it.dart'; + +import '../../models/finamp_models.dart'; +import 'package:finamp/services/queue_service.dart'; + +import 'queue_source_helper.dart'; + +class PlayerScreenAppBarTitle extends StatefulWidget { + const PlayerScreenAppBarTitle({Key? key}) : super(key: key); + + @override + State createState() => + _PlayerScreenAppBarTitleState(); +} + +class _PlayerScreenAppBarTitleState extends State { + final QueueService _queueService = GetIt.instance(); + + @override + Widget build(BuildContext context) { + final currentTrackStream = _queueService.getCurrentTrackStream(); + + return StreamBuilder( + stream: currentTrackStream, + initialData: _queueService.getCurrentTrack(), + builder: (context, snapshot) { + final queueItem = snapshot.data!; + + return Container( + constraints: const BoxConstraints(maxWidth: 235), + child: Baseline( + baselineType: TextBaseline.alphabetic, + baseline: 0, + child: GestureDetector( + onTap: () => navigateToSource(context, queueItem.source), + child: Column( + children: [ + Text( + AppLocalizations.of(context)! + .playingFromType(queueItem.source.type.toString()), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w300, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.7) + : Colors.black.withOpacity(0.8), + ), + overflow: TextOverflow.ellipsis, + ), + const Padding(padding: EdgeInsets.symmetric(vertical: 2)), + Text( + queueItem.source.name.getLocalized(context), + style: TextStyle( + fontSize: 16, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black.withOpacity(0.9), + ), + maxLines: 2, + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/components/PlayerScreen/progress_slider.dart b/lib/components/PlayerScreen/progress_slider.dart index 6debf5634..232506bb0 100644 --- a/lib/components/PlayerScreen/progress_slider.dart +++ b/lib/components/PlayerScreen/progress_slider.dart @@ -46,7 +46,7 @@ class _ProgressSliderState extends State { // RepaintBoundary to avoid more areas being repainted than necessary child: SliderTheme( data: SliderThemeData( - trackHeight: 2.0, + trackHeight: 4.0, trackShape: CustomTrackShape(), ), child: RepaintBoundary( @@ -136,6 +136,15 @@ class _BufferSlider extends StatelessWidget { thumbShape: HiddenThumbComponentShape(), trackShape: BufferTrackShape(), trackHeight: 4.0, + inactiveTrackColor: IconTheme.of(context).color!.withOpacity(0.35), + // thumbColor: Colors.white, + // overlayColor: Colors.white, + activeTrackColor: IconTheme.of(context).color!.withOpacity(0.6), + // disabledThumbColor: Colors.white, + // activeTickMarkColor: Colors.white, + // valueIndicatorColor: Colors.white, + // inactiveTickMarkColor: Colors.white, + // disabledActiveTrackColor: Colors.white, ), child: ExcludeSemantics( child: Slider( @@ -182,17 +191,17 @@ class _ProgressSliderDuration extends StatelessWidget { printDuration( Duration(microseconds: position.inMicroseconds), ), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: Theme.of(context).textTheme.bodySmall?.color), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + height: 0.5, // reduce line height + ), ), Text( printDuration(itemDuration), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: Theme.of(context).textTheme.bodySmall?.color), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + height: 0.5, // reduce line height + ), ), ], ); @@ -234,14 +243,15 @@ class __PlaybackProgressSliderState // ? _sliderThemeData.copyWith( ? SliderTheme.of(context).copyWith( inactiveTrackColor: Colors.transparent, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), ) // ) // : _sliderThemeData.copyWith( : SliderTheme.of(context).copyWith( inactiveTrackColor: Colors.transparent, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 0), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 0.1), // gets rid of both horizontal and vertical padding - overlayShape: const RoundSliderOverlayShape(overlayRadius: 0), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 0.1), trackShape: const RectangularSliderTrackShape(), // rectangular shape is thinner than round trackHeight: 4.0, diff --git a/lib/components/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart index 8857dff51..e35b69f98 100644 --- a/lib/components/PlayerScreen/queue_list.dart +++ b/lib/components/PlayerScreen/queue_list.dart @@ -1,140 +1,1474 @@ import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; +import 'package:finamp/components/AlbumScreen/song_list_tile.dart'; +import 'package:finamp/components/error_snackbar.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/screens/add_to_playlist_screen.dart'; +import 'package:finamp/screens/album_screen.dart'; +import 'package:finamp/screens/blurred_player_screen_background.dart'; +import 'package:finamp/services/audio_service_helper.dart'; +import 'package:finamp/services/downloads_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_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:get_it/get_it.dart'; import 'package:rxdart/rxdart.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; -import '../../services/finamp_settings_helper.dart'; import '../album_image.dart'; -import '../../models/jellyfin_models.dart'; +import '../../models/jellyfin_models.dart' as jellyfin_models; import '../../services/process_artist.dart'; import '../../services/media_state_stream.dart'; import '../../services/music_player_background_task.dart'; +import '../../services/queue_service.dart'; +import 'queue_list_item.dart'; +import 'queue_source_helper.dart'; class _QueueListStreamState { _QueueListStreamState( - this.queue, this.mediaState, + this.queueInfo, ); - final List? queue; final MediaState mediaState; + final FinampQueueInfo? queueInfo; } class QueueList extends StatefulWidget { - const QueueList({Key? key, required this.scrollController}) : super(key: key); + const QueueList({ + Key? key, + required this.scrollController, + required this.previousTracksHeaderKey, + required this.currentTrackKey, + required this.nextUpHeaderKey, + }) : super(key: key); final ScrollController scrollController; + final GlobalKey previousTracksHeaderKey; + final Key currentTrackKey; + final GlobalKey nextUpHeaderKey; @override State createState() => _QueueListState(); + + void scrollDown() { + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(seconds: 2), + curve: Curves.fastOutSlowIn, + ); + } +} + +void scrollToKey({ + required GlobalKey key, + Duration? duration, +}) { + if (duration == null) { + Scrollable.ensureVisible( + key.currentContext!, + ); + } else { + Scrollable.ensureVisible( + key.currentContext!, + duration: duration, + curve: Curves.easeOut, + ); + } } class _QueueListState extends State { - final _audioHandler = GetIt.instance(); - List? _queue; + final _queueService = GetIt.instance(); + + QueueItemSource? _source; + + late List _contents; + BehaviorSubject isRecentTracksExpanded = BehaviorSubject.seeded(false); + + @override + void initState() { + super.initState(); + + _queueService.getQueueStream().listen((queueInfo) { + _source = queueInfo?.source; + }); + + _source = _queueService.getQueue().source; + + _contents = [ + // const SliverPadding(padding: EdgeInsets.only(top: 0)), + // Previous Tracks + SliverList.list( + children: const [], + ), + // Current Track + SliverAppBar( + key: UniqueKey(), + pinned: true, + collapsedHeight: 70.0, + expandedHeight: 70.0, + leading: const Padding( + padding: EdgeInsets.zero, + ), + flexibleSpace: ListTile( + leading: const AlbumImage( + item: null, + ), + title: const Text("unknown"), + subtitle: const Text("unknown"), + onTap: () {}), + ), + SliverPersistentHeader( + delegate: QueueSectionHeader( + source: _source, + title: const Flexible( + child: Text("Queue", overflow: TextOverflow.ellipsis)), + nextUpHeaderKey: widget.nextUpHeaderKey, + )), + // Queue + SliverList.list( + key: widget.nextUpHeaderKey, + children: const [], + ), + ]; + } + + void scrollToCurrentTrack() { + if (widget.previousTracksHeaderKey.currentContext != null) { + Scrollable.ensureVisible( + widget.previousTracksHeaderKey.currentContext!, + // duration: const Duration(milliseconds: 200), + // curve: Curves.decelerate, + ); + } + } @override Widget build(BuildContext context) { - return StreamBuilder<_QueueListStreamState>( - // stream: AudioService.queueStream, - stream: Rx.combineLatest2?, MediaState, - _QueueListStreamState>(_audioHandler.queue, mediaStateStream, - (a, b) => _QueueListStreamState(a, b)), - builder: (context, snapshot) { - if (snapshot.hasData) { - _queue ??= snapshot.data!.queue; - return PrimaryScrollController( - controller: widget.scrollController, - child: Padding( - padding: const EdgeInsets.only(top: 24), - child: ReorderableListView.builder( - itemCount: snapshot.data!.queue?.length ?? 0, - onReorder: (oldIndex, newIndex) async { - setState(() { - // _queue?.insert(newIndex, _queue![oldIndex]); - // _queue?.removeAt(oldIndex); - int? smallerThanNewIndex; - if (oldIndex < newIndex) { - // When we're moving an item backwards, we need to reduce - // newIndex by 1 to account for there being a new item added - // before newIndex. - smallerThanNewIndex = newIndex - 1; - } - final item = _queue?.removeAt(oldIndex); - _queue?.insert(smallerThanNewIndex ?? newIndex, item!); - }); - await _audioHandler.reorderQueue(oldIndex, newIndex); - }, - itemBuilder: (context, index) { - final actualIndex = - _audioHandler.playbackState.valueOrNull?.shuffleMode == - AudioServiceShuffleMode.all - ? _audioHandler.shuffleIndices![index] - : index; - return Dismissible( - key: ValueKey(snapshot.data!.queue![actualIndex].id), - direction: - FinampSettingsHelper.finampSettings.disableGesture - ? DismissDirection.none - : DismissDirection.horizontal, - onDismissed: (direction) async { - await _audioHandler.removeQueueItemAt(actualIndex); - }, - child: ListTile( - leading: AlbumImage( - item: snapshot.data!.queue?[actualIndex] - .extras?["itemJson"] == - null - ? null - : BaseItemDto.fromJson(snapshot.data! - .queue?[actualIndex].extras?["itemJson"]), - ), - title: Text( - snapshot.data!.queue?[actualIndex].title ?? - AppLocalizations.of(context)!.unknownName, - style: snapshot.data!.mediaState.mediaItem == - snapshot.data!.queue?[actualIndex] - ? TextStyle( - color: - Theme.of(context).colorScheme.secondary) - : null), - subtitle: Text(processArtist( - snapshot.data!.queue?[actualIndex].artist, - context)), - onTap: () async => - await _audioHandler.skipToIndex(actualIndex), - ), - ); - }, + _contents = [ + // Previous Tracks + StreamBuilder( + stream: isRecentTracksExpanded, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!) { + return PreviousTracksList( + previousTracksHeaderKey: widget.previousTracksHeaderKey); + } else { + return const SliverToBoxAdapter(); + } + }), + SliverPersistentHeader( + key: widget.previousTracksHeaderKey, + delegate: PreviousTracksSectionHeader( + isRecentTracksExpanded: isRecentTracksExpanded, + previousTracksHeaderKey: widget.previousTracksHeaderKey, + onTap: () => + isRecentTracksExpanded.add(!isRecentTracksExpanded.value), + )), + CurrentTrack( + // key: UniqueKey(), + key: widget.currentTrackKey, + ), + // next up + StreamBuilder( + key: widget.nextUpHeaderKey, + stream: _queueService.getQueueStream(), + builder: (context, snapshot) { + if (snapshot.data != null && snapshot.data!.nextUp.isNotEmpty) { + return SliverPadding( + // key: widget.nextUpHeaderKey, + padding: const EdgeInsets.only(top: 20.0, bottom: 0.0), + sliver: SliverPersistentHeader( + pinned: + false, //TODO use https://stackoverflow.com/a/69372976 to only ever have one of the headers pinned + delegate: NextUpSectionHeader( + controls: true, + nextUpHeaderKey: widget.nextUpHeaderKey, + ), // _source != null ? "Playing from ${_source?.name}" : "Queue", + ), + ); + } else { + return const SliverToBoxAdapter(); + } + }, + ), + NextUpTracksList(previousTracksHeaderKey: widget.previousTracksHeaderKey), + SliverPadding( + padding: const EdgeInsets.only(top: 20.0, bottom: 0.0), + sliver: SliverPersistentHeader( + pinned: true, + delegate: QueueSectionHeader( + source: _source, + title: Row( + children: [ + Text( + "${AppLocalizations.of(context)!.playingFrom} ", + style: const TextStyle(fontWeight: FontWeight.w300), ), - )); - } else { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } - }, + Flexible( + child: Text( + _source?.name.getLocalized(context) ?? + AppLocalizations.of(context)!.unknownName, + style: const TextStyle(fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + controls: true, + nextUpHeaderKey: widget.nextUpHeaderKey, + ), + ), + ), + // Queue + QueueTracksList(previousTracksHeaderKey: widget.previousTracksHeaderKey), + const SliverPadding( + padding: EdgeInsets.only(bottom: 80.0, top: 40.0), + ) + ]; + + return ScrollbarTheme( + data: ScrollbarThemeData( + thumbColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.primary.withOpacity(0.7)), + trackColor: MaterialStateProperty.all( + Theme.of(context).colorScheme.primary.withOpacity(0.2)), + radius: const Radius.circular(6.0), + thickness: MaterialStateProperty.all(12.0), + // thumbVisibility: MaterialStateProperty.all(true), + trackVisibility: MaterialStateProperty.all(false)), + child: Scrollbar( + controller: widget.scrollController, + interactive: true, + child: CustomScrollView( + controller: widget.scrollController, + physics: const BouncingScrollPhysics(), + slivers: _contents, + ), + ), ); } } Future showQueueBottomSheet(BuildContext context) { + GlobalKey previousTracksHeaderKey = GlobalKey(); + Key currentTrackKey = UniqueKey(); + GlobalKey nextUpHeaderKey = GlobalKey(); + + Vibrate.feedback(FeedbackType.impact); + return showModalBottomSheet( + // showDragHandle: true, + useSafeArea: true, + enableDrag: true, isScrollControlled: true, + routeSettings: const RouteSettings(name: "/queue"), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24.0)), ), + clipBehavior: Clip.antiAlias, context: context, builder: (context) { - return DraggableScrollableSheet( - expand: false, - builder: (context, scrollController) { - return QueueList( - scrollController: scrollController, - ); - }, - ); + return Consumer( + builder: (BuildContext context, WidgetRef ref, Widget? child) { + final imageTheme = ref.watch(playerScreenThemeProvider); + + return AnimatedTheme( + duration: const Duration(milliseconds: 500), + data: ThemeData( + fontFamily: "LexendDeca", + colorScheme: imageTheme, + brightness: Theme.of(context).brightness, + iconTheme: Theme.of(context).iconTheme.copyWith( + color: imageTheme?.primary, + ), + ), + child: DraggableScrollableSheet( + snap: false, + snapAnimationDuration: const Duration(milliseconds: 200), + initialChildSize: 0.92, + // maxChildSize: 0.92, + expand: false, + builder: (context, scrollController) { + return Scaffold( + body: Stack( + children: [ + if (FinampSettingsHelper + .finampSettings.showCoverAsPlayerBackground) + BlurredPlayerScreenBackground( + brightnessFactor: + Theme.of(context).brightness == Brightness.dark + ? 1.0 + : 1.0), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 10), + Container( + width: 40, + height: 3.5, + decoration: BoxDecoration( + color: + Theme.of(context).textTheme.bodySmall!.color!, + borderRadius: BorderRadius.circular(3.5), + ), + ), + const SizedBox(height: 10), + Text(AppLocalizations.of(context)!.queue, + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyLarge! + .color!, + fontFamily: 'Lexend Deca', + fontSize: 18, + fontWeight: FontWeight.w300)), + const SizedBox(height: 20), + Expanded( + child: QueueList( + scrollController: scrollController, + previousTracksHeaderKey: previousTracksHeaderKey, + currentTrackKey: currentTrackKey, + nextUpHeaderKey: nextUpHeaderKey, + ), + ), + ], + ), + ], + ), + //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), + ), + )), + ); + // ) + // return QueueList( + // scrollController: scrollController, + // ); + }, + ), + ); + }); }, ); } + +class PreviousTracksList extends StatefulWidget { + final GlobalKey previousTracksHeaderKey; + + const PreviousTracksList({ + Key? key, + required this.previousTracksHeaderKey, + }) : super(key: key); + + @override + State createState() => _PreviousTracksListState(); +} + +class _PreviousTracksListState extends State + with TickerProviderStateMixin { + final _queueService = GetIt.instance(); + List? _previousTracks; + + @override + Widget build(context) { + return StreamBuilder( + stream: _queueService.getQueueStream(), + builder: (context, snapshot) { + if (snapshot.hasData) { + _previousTracks ??= snapshot.data!.previousTracks; + + return SliverReorderableList( + autoScrollerVelocityScalar: 20.0, + onReorder: (oldIndex, newIndex) { + int draggingOffset = -(_previousTracks!.length - oldIndex); + int newPositionOffset = -(_previousTracks!.length - newIndex); + print("$draggingOffset -> $newPositionOffset"); + if (mounted) { + Vibrate.feedback(FeedbackType.impact); + setState(() { + // temporarily update internal queue + FinampQueueItem tmp = _previousTracks!.removeAt(oldIndex); + _previousTracks!.insert( + newIndex < oldIndex ? newIndex : newIndex - 1, tmp); + // update external queue to commit changes, results in a rebuild + _queueService.reorderByOffset( + draggingOffset, newPositionOffset); + }); + } + }, + onReorderStart: (p0) { + Vibrate.feedback(FeedbackType.selection); + }, + findChildIndexCallback: (Key key) { + key = key as GlobalObjectKey; + final ValueKey valueKey = key.value as ValueKey; + // search from the back as this is probably more efficient for previous tracks + final index = _previousTracks! + .lastIndexWhere((item) => item.id == valueKey.value); + if (index == -1) return null; + return index; + }, + itemCount: _previousTracks?.length ?? 0, + itemBuilder: (context, index) { + final item = _previousTracks![index]; + final actualIndex = index; + final indexOffset = -((_previousTracks?.length ?? 0) - index); + return QueueListItem( + key: ValueKey(item.id), + item: item, + listIndex: index, + actualIndex: actualIndex, + indexOffset: indexOffset, + subqueue: _previousTracks!, + 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, + isPreviousTrack: true, + ); + }, + ); + } else { + return SliverList(delegate: SliverChildListDelegate([])); + } + }, + ); + } +} + +class NextUpTracksList extends StatefulWidget { + final GlobalKey previousTracksHeaderKey; + + const NextUpTracksList({ + Key? key, + required this.previousTracksHeaderKey, + }) : super(key: key); + + @override + State createState() => _NextUpTracksListState(); +} + +class _NextUpTracksListState extends State { + final _queueService = GetIt.instance(); + List? _nextUp; + + @override + Widget build(context) { + return StreamBuilder( + stream: _queueService.getQueueStream(), + builder: (context, snapshot) { + if (snapshot.hasData) { + _nextUp ??= snapshot.data!.nextUp; + + return SliverPadding( + padding: const EdgeInsets.only(top: 0.0, left: 4.0, right: 4.0), + sliver: SliverReorderableList( + autoScrollerVelocityScalar: 20.0, + onReorder: (oldIndex, newIndex) { + int draggingOffset = oldIndex + 1; + int newPositionOffset = newIndex + 1; + if (mounted) { + Vibrate.feedback(FeedbackType.impact); + setState(() { + // temporarily update internal queue + FinampQueueItem tmp = _nextUp!.removeAt(oldIndex); + _nextUp!.insert( + newIndex < oldIndex ? newIndex : newIndex - 1, tmp); + // update external queue to commit changes, results in a rebuild + _queueService.reorderByOffset( + draggingOffset, newPositionOffset); + }); + } + }, + onReorderStart: (p0) { + Vibrate.feedback(FeedbackType.selection); + }, + findChildIndexCallback: (Key key) { + key = key as GlobalObjectKey; + final ValueKey valueKey = + key.value as ValueKey; + final index = + _nextUp!.indexWhere((item) => item.id == valueKey.value); + if (index == -1) return null; + return index; + }, + itemCount: _nextUp?.length ?? 0, + itemBuilder: (context, index) { + final item = _nextUp![index]; + final actualIndex = index; + final indexOffset = index + 1; + return QueueListItem( + key: ValueKey(item.id), + item: item, + listIndex: index, + actualIndex: actualIndex, + indexOffset: indexOffset, + subqueue: _nextUp!, + 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([])); + } + }, + ); + } +} + +class QueueTracksList extends StatefulWidget { + final GlobalKey previousTracksHeaderKey; + + const QueueTracksList({ + Key? key, + required this.previousTracksHeaderKey, + }) : super(key: key); + + @override + State createState() => _QueueTracksListState(); +} + +class _QueueTracksListState extends State { + final _queueService = GetIt.instance(); + List? _queue; + List? _nextUp; + + @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([])); + } + }, + ); + } +} + +class CurrentTrack extends StatefulWidget { + const CurrentTrack({ + Key? key, + }) : super(key: key); + + @override + State createState() => _CurrentTrackState(); +} + +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 + Widget build(context) { + FinampQueueItem? currentTrack; + MediaState? mediaState; + Duration? playbackPosition; + + return StreamBuilder<_QueueListStreamState>( + stream: Rx.combineLatest2( + mediaStateStream, + _queueService.getQueueStream(), + (a, b) => _QueueListStreamState(a, b)), + builder: (context, snapshot) { + if (snapshot.hasData) { + currentTrack = snapshot.data!.queueInfo?.currentTrack; + mediaState = snapshot.data!.mediaState; + + jellyfin_models.BaseItemDto? baseItem = + currentTrack!.item.extras?["itemJson"] == null + ? null + : jellyfin_models.BaseItemDto.fromJson( + currentTrack!.item.extras?["itemJson"]); + + const horizontalPadding = 8.0; + const albumImageSize = 70.0; + + return SliverAppBar( + pinned: true, + collapsedHeight: 70.0, + expandedHeight: 70.0, + elevation: 10.0, + leading: const Padding( + padding: EdgeInsets.zero, + ), + backgroundColor: const Color.fromRGBO(0, 0, 0, 0.0), + flexibleSpace: Container( + // width: 58, + height: albumImageSize, + padding: + const EdgeInsets.symmetric(horizontal: horizontalPadding), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: Color.alphaBlend( + Theme.of(context).brightness == Brightness.dark + ? IconTheme.of(context).color!.withOpacity(0.35) + : IconTheme.of(context).color!.withOpacity(0.5), + Theme.of(context).brightness == Brightness.dark + ? Colors.black + : Colors.white), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + AlbumImage( + item: baseItem, + borderRadius: BorderRadius.zero, + itemsToPrecache: + _queueService.getNextXTracksInQueue(3).map((e) { + final item = e.item.extras?["itemJson"] != null + ? jellyfin_models.BaseItemDto.fromJson( + e.item.extras!["itemJson"] + as Map) + : null; + return item!; + }).toList(), + ), + Container( + width: albumImageSize, + height: albumImageSize, + decoration: const ShapeDecoration( + shape: Border(), + color: Color.fromRGBO(0, 0, 0, 0.3), + ), + child: IconButton( + onPressed: () { + Vibrate.feedback(FeedbackType.success); + _audioHandler.togglePlayback(); + }, + icon: mediaState!.playbackState.playing + ? const Icon( + TablerIcons.player_pause, + size: 32, + ) + : const Icon( + TablerIcons.player_play, + size: 32, + ), + color: Colors.white, + )), + ], + ), + Expanded( + child: Stack( + children: [ + Positioned( + left: 0, + top: 0, + child: StreamBuilder( + stream: AudioService.position.startWith( + _audioHandler.playbackState.value.position), + builder: (context, snapshot) { + if (snapshot.hasData) { + playbackPosition = snapshot.data; + final screenSize = + 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 - + 2 * horizontalPadding - + albumImageSize) * + (playbackPosition!.inMilliseconds / + (mediaState?.mediaItem + ?.duration ?? + const Duration( + seconds: 0)) + .inMilliseconds), + height: 70.0, + decoration: ShapeDecoration( + color: IconTheme.of(context) + .color! + .withOpacity(0.75), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + ), + ); + } else { + return Container(); + } + }), + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Container( + height: albumImageSize, + padding: + const EdgeInsets.only(left: 12, right: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + currentTrack?.item.title ?? + AppLocalizations.of(context)! + .unknownName, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: 'Lexend Deca', + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + processArtist( + currentTrack!.item.artist, + context), + style: TextStyle( + color: Colors.white + .withOpacity(0.85), + fontSize: 13, + fontFamily: 'Lexend Deca', + fontWeight: FontWeight.w300, + overflow: + TextOverflow.ellipsis), + ), + ), + Row( + children: [ + StreamBuilder( + stream: AudioService.position + .startWith(_audioHandler + .playbackState + .value + .position), + builder: (context, snapshot) { + final TextStyle style = + TextStyle( + color: Colors.white + .withOpacity(0.8), + fontSize: 14, + fontFamily: 'Lexend Deca', + fontWeight: + FontWeight.w400, + ); + if (snapshot.hasData) { + playbackPosition = + snapshot.data; + return Text( + // '0:00', + playbackPosition! + .inHours >= + 1.0 + ? "${playbackPosition?.inHours.toString()}:${((playbackPosition?.inMinutes ?? 0) % 60).toString().padLeft(2, '0')}:${((playbackPosition?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}" + : "${playbackPosition?.inMinutes.toString()}:${((playbackPosition?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}", + style: style, + ); + } else { + return Text( + "0:00", + style: style, + ); + } + }), + const SizedBox(width: 2), + Text( + '/', + style: TextStyle( + color: Colors.white + .withOpacity(0.8), + fontSize: 14, + fontFamily: 'Lexend Deca', + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 2), + Text( + // '3:44', + (mediaState?.mediaItem?.duration + ?.inHours ?? + 0.0) >= + 1.0 + ? "${mediaState?.mediaItem?.duration?.inHours.toString()}:${((mediaState?.mediaItem?.duration?.inMinutes ?? 0) % 60).toString().padLeft(2, '0')}:${((mediaState?.mediaItem?.duration?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}" + : "${mediaState?.mediaItem?.duration?.inMinutes.toString()}:${((mediaState?.mediaItem?.duration?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}", + style: TextStyle( + color: Colors.white + .withOpacity(0.8), + fontSize: 14, + fontFamily: 'Lexend Deca', + fontWeight: FontWeight.w400, + ), + ), + ], + ) + ], + ), + ], + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: IconButton( + iconSize: 16, + visualDensity: + const VisualDensity(horizontal: -4), + icon: + jellyfin_models.BaseItemDto.fromJson( + currentTrack!.item + .extras?["itemJson"]) + .userData! + .isFavorite + ? Icon( + Icons.favorite, + size: 28, + color: IconTheme.of(context) + .color!, + fill: 1.0, + weight: 1.5, + ) + : const Icon( + Icons.favorite_outline, + size: 28, + color: Colors.white, + weight: 1.5, + ), + onPressed: () { + Vibrate.feedback(FeedbackType.success); + setState(() { + setFavourite(currentTrack!); + }); + }, + ), + ), + IconButton( + iconSize: 28, + visualDensity: + const VisualDensity(horizontal: -4), + // visualDensity: VisualDensity.compact, + icon: const Icon( + TablerIcons.dots_vertical, + size: 28, + color: Colors.white, + weight: 1.5, + ), + onPressed: () => + showSongMenu(currentTrack!), + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } else { + return SliverList(delegate: SliverChildListDelegate([])); + } + }, + ); + } + + void showSongMenu(FinampQueueItem currentTrack) async { + final item = jellyfin_models.BaseItemDto.fromJson( + currentTrack.item.extras?["itemJson"]); + + final canGoToAlbum = _isAlbumDownloadedIfOffline(item.parentId); + + // Some options are disabled in offline mode + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + final selection = await showMenu( + context: context, + position: RelativeRect.fromLTRB(MediaQuery.of(context).size.width - 50.0, + MediaQuery.of(context).size.height - 50.0, 0.0, 0.0), + items: [ + 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(TablerIcons.hourglass_low), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), + PopupMenuItem( + value: SongListTileMenuItems.addToNextUp, + child: ListTile( + leading: const Icon(TablerIcons.hourglass_high), + title: Text(AppLocalizations.of(context)!.addToNextUp), + ), + ), + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.addToPlaylist, + child: ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(AppLocalizations.of(context)!.addToPlaylistTitle), + enabled: !isOffline, + ), + ), + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.instantMix, + child: ListTile( + leading: const Icon(Icons.explore), + title: Text(AppLocalizations.of(context)!.instantMix), + enabled: !isOffline, + ), + ), + PopupMenuItem( + enabled: canGoToAlbum, + value: SongListTileMenuItems.goToAlbum, + child: ListTile( + leading: const Icon(Icons.album), + title: Text(AppLocalizations.of(context)!.goToAlbum), + enabled: canGoToAlbum, + ), + ), + item.userData!.isFavorite + ? PopupMenuItem( + value: SongListTileMenuItems.removeFavourite, + child: ListTile( + leading: const Icon(Icons.favorite_border), + title: Text(AppLocalizations.of(context)!.removeFavourite), + ), + ) + : PopupMenuItem( + value: SongListTileMenuItems.addFavourite, + child: ListTile( + leading: const Icon(Icons.favorite), + title: Text(AppLocalizations.of(context)!.addFavourite), + ), + ), + ], + ); + + if (!mounted) return; + + switch (selection) { + case SongListTileMenuItems.addToQueue: + await _queueService.addToQueue( + items: [item], + source: QueueItemSource( + type: QueueItemSourceType.unknown, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: AppLocalizations.of(context)!.queue), + id: currentTrack.source.id)); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.addedToQueue), + )); + break; + + case SongListTileMenuItems.playNext: + await _queueService.addNext(items: [item]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.confirmPlayNext("track")), + )); + break; + + case SongListTileMenuItems.addToNextUp: + await _queueService.addToNextUp(items: [item]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.confirmAddToNextUp("track")), + )); + break; + + case SongListTileMenuItems.addToPlaylist: + Navigator.of(context) + .pushNamed(AddToPlaylistScreen.routeName, arguments: item.id); + break; + + case SongListTileMenuItems.instantMix: + await _audioServiceHelper.startInstantMixForItem(item); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.startingInstantMix), + )); + break; + case SongListTileMenuItems.goToAlbum: + late jellyfin_models.BaseItemDto album; + if (FinampSettingsHelper.finampSettings.isOffline) { + // If offline, load the album's BaseItemDto from DownloadHelper. + final downloadsHelper = GetIt.instance(); + + // downloadedParent won't be null here since the menu item already + // checks if the DownloadedParent exists. + album = downloadsHelper.getDownloadedParent(item.parentId!)!.item; + } else { + // If online, get the album's BaseItemDto from the server. + try { + album = await _jellyfinApiHelper.getItemById(item.parentId!); + } catch (e) { + errorSnackbar(e, context); + break; + } + } + + if (!mounted) return; + + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: album); + break; + case SongListTileMenuItems.addFavourite: + case SongListTileMenuItems.removeFavourite: + await setFavourite(currentTrack); + break; + case null: + break; + } + } + + Future setFavourite(FinampQueueItem track) async { + try { + // We switch the widget state before actually doing the request to + // make the app feel faster (without, there is a delay from the + // user adding the favourite and the icon showing) + jellyfin_models.BaseItemDto item = + jellyfin_models.BaseItemDto.fromJson(track.item.extras!["itemJson"]); + + setState(() { + item.userData!.isFavorite = !item.userData!.isFavorite; + }); + + // Since we flipped the favourite state already, we can use the flipped + // state to decide which API call to make + final newUserData = item.userData!.isFavorite + ? await _jellyfinApiHelper.addFavourite(item.id) + : await _jellyfinApiHelper.removeFavourite(item.id); + + item.userData = newUserData; + + if (!mounted) return; + setState(() { + //!!! update the QueueItem with the new BaseItemDto, then trigger a rebuild of the widget with the current snapshot (**which includes the modified QueueItem**) + track.item.extras!["itemJson"] = item.toJson(); + }); + + _queueService.refreshQueueStream(); + } catch (e) { + errorSnackbar(e, context); + } + } +} + +class PlaybackBehaviorInfo { + final FinampPlaybackOrder order; + final FinampLoopMode loop; + + PlaybackBehaviorInfo(this.order, this.loop); +} + +class QueueSectionHeader extends SliverPersistentHeaderDelegate { + final Widget title; + final QueueItemSource? source; + final bool controls; + final double height; + final GlobalKey nextUpHeaderKey; + + QueueSectionHeader({ + required this.title, + required this.source, + required this.nextUpHeaderKey, + this.controls = false, + this.height = 30.0, + }); + + @override + Widget build(context, double shrinkOffset, bool overlapsContent) { + final queueService = GetIt.instance(); + + return StreamBuilder( + stream: Rx.combineLatest2( + queueService.getPlaybackOrderStream(), + queueService.getLoopModeStream(), + (a, b) => PlaybackBehaviorInfo(a, b)), + builder: (context, snapshot) { + PlaybackBehaviorInfo? info = snapshot.data; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: GestureDetector( + child: title, + onTap: () { + if (source != null) { + navigateToSource(context, source!); + } + }), + ), + if (controls) + Row( + children: [ + IconButton( + padding: const EdgeInsets.only(bottom: 2.0), + iconSize: 28.0, + icon: info?.order == FinampPlaybackOrder.shuffled + ? (const Icon( + TablerIcons.arrows_shuffle, + )) + : (const Icon( + TablerIcons.arrows_right, + )), + color: info?.order == FinampPlaybackOrder.shuffled + ? IconTheme.of(context).color! + : Colors.white, + onPressed: () { + queueService.togglePlaybackOrder(); + Vibrate.feedback(FeedbackType.success); + Future.delayed( + const Duration(milliseconds: 300), + () => scrollToKey( + key: nextUpHeaderKey, + duration: const Duration(milliseconds: 500))); + // scrollToKey(key: nextUpHeaderKey, duration: const Duration(milliseconds: 1000)); + }), + IconButton( + padding: const EdgeInsets.only(bottom: 2.0), + iconSize: 28.0, + icon: info?.loop != FinampLoopMode.none + ? (info?.loop == FinampLoopMode.one + ? (const Icon( + TablerIcons.repeat_once, + )) + : (const Icon( + TablerIcons.repeat, + ))) + : (const Icon( + TablerIcons.repeat_off, + )), + color: info?.loop != FinampLoopMode.none + ? IconTheme.of(context).color! + : Colors.white, + onPressed: () { + queueService.toggleLoopMode(); + Vibrate.feedback(FeedbackType.success); + }), + ], + ) + // Expanded( + // child: Flex( + // direction: Axis.horizontal, + // crossAxisAlignment: CrossAxisAlignment.center, + // clipBehavior: Clip.hardEdge, + // children: [ + // , + // ])), + + // ) + ], + ), + ); + }, + ); + } + + @override + double get maxExtent => height; + + @override + double get minExtent => height; + + @override + bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; +} + +class NextUpSectionHeader extends SliverPersistentHeaderDelegate { + final bool controls; + final double height; + final GlobalKey nextUpHeaderKey; + + NextUpSectionHeader({ + required this.nextUpHeaderKey, + this.controls = false, + this.height = 30.0, + }); + + @override + Widget build(context, double shrinkOffset, bool overlapsContent) { + final _queueService = GetIt.instance(); + + return Container( + // color: Colors.black.withOpacity(0.5), + padding: const EdgeInsets.symmetric(horizontal: 14.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(AppLocalizations.of(context)!.nextUp), + ])), + if (controls) + GestureDetector( + onTap: () { + _queueService.clearNextUp(); + Vibrate.feedback(FeedbackType.success); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text(AppLocalizations.of(context)!.clearNextUp), + ), + const Icon( + TablerIcons.x, + color: Colors.white, + size: 32.0, + ), + ], + ), + ), + ], + ), + ); + } + + @override + double get maxExtent => height; + + @override + double get minExtent => height; + + @override + bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; +} + +class PreviousTracksSectionHeader extends SliverPersistentHeaderDelegate { + // final bool controls; + final double height; + final VoidCallback? onTap; + final GlobalKey previousTracksHeaderKey; + final BehaviorSubject isRecentTracksExpanded; + + PreviousTracksSectionHeader({ + required this.previousTracksHeaderKey, + required this.isRecentTracksExpanded, + // this.controls = false, + this.onTap, + this.height = 50.0, + }); + + @override + Widget build(context, double shrinkOffset, bool overlapsContent) { + return Padding( + // color: Colors.black.withOpacity(0.5), + padding: const EdgeInsets.only( + left: 14.0, right: 14.0, bottom: 12.0, top: 8.0), + child: GestureDetector( + onTap: () { + try { + if (onTap != null) { + onTap!(); + Vibrate.feedback(FeedbackType.selection); + } + } catch (e) { + Vibrate.feedback(FeedbackType.error); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text(AppLocalizations.of(context)!.previousTracks), + ), + const SizedBox(width: 4.0), + StreamBuilder( + stream: isRecentTracksExpanded, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!) { + return Icon( + TablerIcons.chevron_up, + size: 28.0, + color: Theme.of(context).iconTheme.color!, + ); + } else { + return Icon( + TablerIcons.chevron_down, + size: 28.0, + color: Theme.of(context).iconTheme.color!, + ); + } + }), + ], + ), + ), + ); + } + + @override + double get maxExtent => height; + + @override + double get minExtent => height; + + @override + bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; +} + +/// If offline, check if an album is downloaded. Always returns true if online. +/// Returns false if albumId is null. +bool _isAlbumDownloadedIfOffline(String? albumId) { + if (albumId == null) { + return false; + } else if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsHelper = GetIt.instance(); + return downloadsHelper.isAlbumDownloaded(albumId); + } else { + return true; + } +} diff --git a/lib/components/PlayerScreen/queue_list_item.dart b/lib/components/PlayerScreen/queue_list_item.dart new file mode 100644 index 000000000..32639e69f --- /dev/null +++ b/lib/components/PlayerScreen/queue_list_item.dart @@ -0,0 +1,439 @@ +import 'package:finamp/components/AlbumScreen/song_list_tile.dart'; +import 'package:finamp/components/album_image.dart'; +import 'package:finamp/components/error_snackbar.dart'; +import 'package:finamp/screens/add_to_playlist_screen.dart'; +import 'package:finamp/screens/album_screen.dart'; +import 'package:finamp/services/audio_service_helper.dart'; +import 'package:finamp/services/downloads_helper.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:finamp/services/process_artist.dart'; +import 'package:flutter/material.dart' hide ReorderableList; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; +import 'package:finamp/services/queue_service.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 QueueListItem extends StatefulWidget { + final FinampQueueItem item; + final int listIndex; + final int actualIndex; + final int indexOffset; + final List subqueue; + final bool isCurrentTrack; + final bool isPreviousTrack; + final bool allowReorder; + final void Function() onTap; + + const QueueListItem({ + Key? key, + required this.item, + required this.listIndex, + required this.actualIndex, + required this.indexOffset, + required this.subqueue, + required this.onTap, + this.allowReorder = true, + this.isCurrentTrack = false, + this.isPreviousTrack = false, + }) : super(key: key); + @override + State createState() => _QueueListItemState(); +} + +class _QueueListItemState extends State + with AutomaticKeepAliveClientMixin { + final _audioServiceHelper = GetIt.instance(); + final _queueService = GetIt.instance(); + final _jellyfinApiHelper = GetIt.instance(); + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + + return Dismissible( + key: Key(widget.item.id), + onDismissed: (direction) async { + Vibrate.feedback(FeedbackType.impact); + await _queueService.removeAtOffset(widget.indexOffset); + setState(() {}); + }, + child: GestureDetector( + onLongPressStart: (details) => showSongMenu(details), + child: Opacity( + opacity: widget.isPreviousTrack ? 0.8 : 1.0, + child: Card( + color: const Color.fromRGBO(255, 255, 255, 0.075), + elevation: 0, + margin: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 5.0), + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + child: ListTile( + visualDensity: VisualDensity.standard, + minVerticalPadding: 0.0, + horizontalTitleGap: 10.0, + contentPadding: const EdgeInsets.symmetric( + vertical: 0.0, horizontal: 0.0), + tileColor: widget.isCurrentTrack + ? Theme.of(context).colorScheme.secondary.withOpacity(0.1) + : null, + leading: AlbumImage( + item: widget.item.item.extras?["itemJson"] == null + ? null + : jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]), + borderRadius: BorderRadius.zero, + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(0.0), + child: Text( + widget.item.item.title, + style: widget.isCurrentTrack + ? TextStyle( + color: + Theme.of(context).colorScheme.secondary, + fontSize: 16, + fontFamily: 'Lexend Deca', + fontWeight: FontWeight.w400, + overflow: TextOverflow.ellipsis) + : null, + overflow: TextOverflow.ellipsis, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Text( + processArtist(widget.item.item.artist, context), + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyMedium! + .color!, + fontSize: 13, + fontFamily: 'Lexend Deca', + fontWeight: FontWeight.w300, + overflow: TextOverflow.ellipsis), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + trailing: Container( + alignment: Alignment.centerRight, + margin: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 6.0), + width: widget.allowReorder + ? 72.0 + : 42.0, //TODO make this responsive + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "${widget.item.item.duration?.inMinutes.toString()}:${((widget.item.item.duration?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}", + textAlign: TextAlign.end, + style: TextStyle( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + if (widget.allowReorder) + ReorderableDragStartListener( + index: widget.listIndex, + child: const Padding( + padding: EdgeInsets.only(bottom: 5.0, left: 6.0), + child: Icon( + TablerIcons.grip_horizontal, + color: Colors.white, + size: 28.0, + weight: 1.5, + ), + ), + ), + ], + ), + ), + onTap: widget.onTap, + )), + )), + ); + } + + void showSongMenu(LongPressStartDetails? details) async { + final canGoToAlbum = _isAlbumDownloadedIfOffline( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .parentId); + + // Some options are disabled in offline mode + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + final screenSize = MediaQuery.of(context).size; + + Feedback.forLongPress(context); + + final selection = await showMenu( + context: context, + position: details != null + ? RelativeRect.fromLTRB( + details.globalPosition.dx, + details.globalPosition.dy, + screenSize.width - details.globalPosition.dx, + screenSize.height - details.globalPosition.dy, + ) + : RelativeRect.fromLTRB(MediaQuery.of(context).size.width - 50.0, + MediaQuery.of(context).size.height - 50.0, 0.0, 0.0), + items: [ + 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(TablerIcons.hourglass_low), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), + PopupMenuItem( + value: SongListTileMenuItems.addToNextUp, + child: ListTile( + leading: const Icon(TablerIcons.hourglass_high), + title: Text(AppLocalizations.of(context)!.addToNextUp), + ), + ), + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.addToPlaylist, + child: ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(AppLocalizations.of(context)!.addToPlaylistTitle), + enabled: !isOffline, + ), + ), + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.instantMix, + child: ListTile( + leading: const Icon(Icons.explore), + title: Text(AppLocalizations.of(context)!.instantMix), + enabled: !isOffline, + ), + ), + PopupMenuItem( + enabled: canGoToAlbum, + value: SongListTileMenuItems.goToAlbum, + child: ListTile( + leading: const Icon(Icons.album), + title: Text(AppLocalizations.of(context)!.goToAlbum), + enabled: canGoToAlbum, + ), + ), + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .userData! + .isFavorite + ? PopupMenuItem( + value: SongListTileMenuItems.removeFavourite, + child: ListTile( + leading: const Icon(Icons.favorite_border), + title: Text(AppLocalizations.of(context)!.removeFavourite), + ), + ) + : PopupMenuItem( + value: SongListTileMenuItems.addFavourite, + child: ListTile( + leading: const Icon(Icons.favorite), + title: Text(AppLocalizations.of(context)!.addFavourite), + ), + ), + ], + ); + + if (!mounted) return; + + switch (selection) { + case SongListTileMenuItems.addToQueue: + await _queueService.addToQueue( + items: [ + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + ], + source: QueueItemSource( + type: QueueItemSourceType.unknown, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: AppLocalizations.of(context)!.queue), + id: widget.item.source.id)); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.addedToQueue), + )); + break; + + case SongListTileMenuItems.playNext: + await _queueService.addNext(items: [ + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + ]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.confirmPlayNext("track")), + )); + break; + + case SongListTileMenuItems.addToNextUp: + await _queueService.addToNextUp(items: [ + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + ]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.confirmAddToNextUp("track")), + )); + break; + + case SongListTileMenuItems.addToPlaylist: + Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName, + arguments: jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .id); + break; + + case SongListTileMenuItems.instantMix: + await _audioServiceHelper.startInstantMixForItem( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"])); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.startingInstantMix), + )); + break; + case SongListTileMenuItems.goToAlbum: + late jellyfin_models.BaseItemDto album; + if (FinampSettingsHelper.finampSettings.isOffline) { + // If offline, load the album's BaseItemDto from DownloadHelper. + final downloadsHelper = GetIt.instance(); + + // downloadedParent won't be null here since the menu item already + // checks if the DownloadedParent exists. + album = downloadsHelper + .getDownloadedParent(jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .parentId!)! + .item; + } else { + // If online, get the album's BaseItemDto from the server. + try { + album = await _jellyfinApiHelper.getItemById( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .parentId!); + } catch (e) { + errorSnackbar(e, context); + break; + } + } + + if (!mounted) return; + + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: album); + break; + case SongListTileMenuItems.addFavourite: + case SongListTileMenuItems.removeFavourite: + await setFavourite(); + break; + case null: + break; + } + } + + Future setFavourite() async { + try { + // We switch the widget state before actually doing the request to + // make the app feel faster (without, there is a delay from the + // user adding the favourite and the icon showing) + setState(() { + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .userData! + .isFavorite = !jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .userData! + .isFavorite; + }); + + // Since we flipped the favourite state already, we can use the flipped + // state to decide which API call to make + final newUserData = jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .userData! + .isFavorite + ? await _jellyfinApiHelper.addFavourite( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .id) + : await _jellyfinApiHelper.removeFavourite( + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .id); + + if (!mounted) return; + + setState(() { + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .userData = newUserData; + }); + } catch (e) { + setState(() { + jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .userData! + .isFavorite = !jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]) + .userData! + .isFavorite; + }); + errorSnackbar(e, context); + } + } +} + +/// If offline, check if an album is downloaded. Always returns true if online. +/// Returns false if albumId is null. +bool _isAlbumDownloadedIfOffline(String? albumId) { + if (albumId == null) { + return false; + } else if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsHelper = GetIt.instance(); + return downloadsHelper.isAlbumDownloaded(albumId); + } else { + return true; + } +} diff --git a/lib/components/PlayerScreen/queue_source_helper.dart b/lib/components/PlayerScreen/queue_source_helper.dart new file mode 100644 index 000000000..026716466 --- /dev/null +++ b/lib/components/PlayerScreen/queue_source_helper.dart @@ -0,0 +1,72 @@ +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/screens/album_screen.dart'; +import 'package:finamp/screens/artist_screen.dart'; +import 'package:finamp/screens/music_screen.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; + +void navigateToSource(BuildContext context, QueueItemSource source) async { + switch (source.type) { + case QueueItemSourceType.album: + case QueueItemSourceType.nextUpAlbum: + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: source.item); + break; + case QueueItemSourceType.artist: + case QueueItemSourceType.nextUpArtist: + Navigator.of(context) + .pushNamed(ArtistScreen.routeName, arguments: source.item); + break; + case QueueItemSourceType.genre: + Navigator.of(context) + .pushNamed(ArtistScreen.routeName, arguments: source.item); + break; + case QueueItemSourceType.playlist: + case QueueItemSourceType.nextUpPlaylist: + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: source.item); + break; + case QueueItemSourceType.albumMix: + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: source.item); + break; + case QueueItemSourceType.artistMix: + Navigator.of(context) + .pushNamed(ArtistScreen.routeName, arguments: source.item); + break; + case QueueItemSourceType.allSongs: + Navigator.of(context).pushNamed(MusicScreen.routeName, + arguments: FinampSettingsHelper.finampSettings.showTabs.entries + .where((element) => element.value == true) + .map((e) => e.key) + .toList() + .indexOf(TabContentType.songs)); + break; + case QueueItemSourceType.nextUp: + break; + case QueueItemSourceType.formerNextUp: + break; + case QueueItemSourceType.unknown: + break; + case QueueItemSourceType.favorites: + case QueueItemSourceType.songMix: + case QueueItemSourceType.filteredList: + case QueueItemSourceType.downloads: + default: + Vibrate.feedback(FeedbackType.warning); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Not implemented yet."), + // action: SnackBarAction( + // label: "OPEN", + // onPressed: () { + // Navigator.of(context).pushNamed( + // "/music/albumscreen", + // arguments: snapshot.data![index]); + // }, + // ), + ), + ); + } +} diff --git a/lib/components/PlayerScreen/sleep_timer_button.dart b/lib/components/PlayerScreen/sleep_timer_button.dart index a2178014a..d14911be8 100644 --- a/lib/components/PlayerScreen/sleep_timer_button.dart +++ b/lib/components/PlayerScreen/sleep_timer_button.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:get_it/get_it.dart'; import '../../services/music_player_background_task.dart'; @@ -18,12 +19,9 @@ class SleepTimerButton extends StatelessWidget { return ValueListenableBuilder( valueListenable: audioHandler.sleepTimer, builder: (context, value, child) { - return IconButton( - icon: value == null - ? const Icon(Icons.mode_night_outlined) - : const Icon(Icons.mode_night), - tooltip: AppLocalizations.of(context)!.sleepTimerTooltip, - onPressed: () async { + return ListTile( + leading: const Icon(TablerIcons.hourglass_high), + onTap: () async { if (value != null) { showDialog( context: context, @@ -35,7 +33,8 @@ class SleepTimerButton extends StatelessWidget { builder: (context) => const SleepTimerDialog(), ); } - }); + }, + title: Text(AppLocalizations.of(context)!.sleepTimerTooltip)); }, ); } diff --git a/lib/components/PlayerScreen/song_info.dart b/lib/components/PlayerScreen/song_info.dart index d408ddb52..514779fc7 100644 --- a/lib/components/PlayerScreen/song_info.dart +++ b/lib/components/PlayerScreen/song_info.dart @@ -1,6 +1,8 @@ import 'dart:math'; import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/finamp_models.dart'; +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'; @@ -9,7 +11,7 @@ import 'package:get_it/get_it.dart'; import 'package:palette_generator/palette_generator.dart'; import '../../generate_material_color.dart'; -import '../../models/jellyfin_models.dart'; +import '../../models/jellyfin_models.dart' as jellyfin_models; import '../../screens/artist_screen.dart'; import '../../services/current_album_image_provider.dart'; import '../../services/finamp_settings_helper.dart'; @@ -32,22 +34,26 @@ class SongInfo extends StatefulWidget { class _SongInfoState extends State { final audioHandler = GetIt.instance(); final jellyfinApiHelper = GetIt.instance(); + final queueService = GetIt.instance(); @override Widget build(BuildContext context) { - return StreamBuilder( - stream: audioHandler.mediaItem, - initialData: MediaItem( - id: "", - title: AppLocalizations.of(context)!.noItem, - album: AppLocalizations.of(context)!.noAlbum, - artist: AppLocalizations.of(context)!.noArtist, - ), + return StreamBuilder( + stream: queueService.getQueueStream(), builder: (context, snapshot) { - final mediaItem = snapshot.data!; + if (!snapshot.hasData) { + // show loading indicator + return const Center( + child: CircularProgressIndicator(), + ); + } + + final currentTrack = snapshot.data!.currentTrack!; + final mediaItem = currentTrack.item; final songBaseItemDto = (mediaItem.extras?.containsKey("itemJson") ?? false) - ? BaseItemDto.fromJson(mediaItem.extras!["itemJson"]) + ? jellyfin_models.BaseItemDto.fromJson( + mediaItem.extras!["itemJson"]) : null; List separatedArtistTextSpans = []; @@ -96,13 +102,12 @@ class _SongInfoState extends State { } return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, children: [ - _PlayerScreenAlbumImage(item: songBaseItemDto), - const Padding(padding: EdgeInsets.symmetric(vertical: 6)), + _PlayerScreenAlbumImage(queueItem: currentTrack), SongNameContent( - songBaseItemDto: songBaseItemDto, - mediaItem: mediaItem, + currentTrack: currentTrack, separatedArtistTextSpans: separatedArtistTextSpans, secondaryTextColour: secondaryTextColour, ) @@ -114,16 +119,21 @@ class _SongInfoState extends State { } class _PlayerScreenAlbumImage extends ConsumerWidget { - const _PlayerScreenAlbumImage({ + _PlayerScreenAlbumImage({ Key? key, - required this.item, + required this.queueItem, }) : super(key: key); - final BaseItemDto? item; + final FinampQueueItem queueItem; @override Widget build(BuildContext context, WidgetRef ref) { - final audioHandler = GetIt.instance(); + final queueService = GetIt.instance(); + + final item = queueItem.item.extras?["itemJson"] != null + ? jellyfin_models.BaseItemDto.fromJson( + queueItem.item.extras!["itemJson"] as Map) + : null; return Container( decoration: BoxDecoration( @@ -137,7 +147,7 @@ class _PlayerScreenAlbumImage extends ConsumerWidget { ), alignment: Alignment.center, constraints: const BoxConstraints( - maxHeight: 300, + maxHeight: 320, // maxWidth: 300, // minHeight: 300, // minWidth: 300, @@ -146,16 +156,16 @@ class _PlayerScreenAlbumImage extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 40), child: AlbumImage( item: item, - // Here we awkwardly get the next 3 queue items so that we + // Here we get the next 3 queue items so that we // can precache them (so that the image is already loaded // when the next song comes on). - itemsToPrecache: audioHandler.queue.value - .sublist(min( - (audioHandler.playbackState.value.queueIndex ?? 0) + 1, - audioHandler.queue.value.length)) - .take(3) - .map((e) => BaseItemDto.fromJson(e.extras!["itemJson"])) - .toList(), + itemsToPrecache: queueService.getNextXTracksInQueue(3).map((e) { + final item = e.item.extras?["itemJson"] != null + ? jellyfin_models.BaseItemDto.fromJson( + e.item.extras!["itemJson"] as Map) + : null; + return item!; + }).toList(), // We need a post frame callback because otherwise this // widget rebuilds on the same frame imageProviderCallback: (imageProvider) => @@ -177,21 +187,30 @@ class _PlayerScreenAlbumImage extends ConsumerWidget { final paletteGenerator = await PaletteGenerator.fromImageProvider(imageProvider); - final accent = paletteGenerator.dominantColor!.color; + Color accent = paletteGenerator.dominantColor!.color; final lighter = theme.brightness == Brightness.dark; + + // increase saturation + if (!lighter) { + final hsv = HSVColor.fromColor(accent); + final newSaturation = min(1.0, hsv.saturation * 2); + final adjustedHsv = hsv.withSaturation(newSaturation); + accent = adjustedHsv.toColor(); + } + final background = Color.alphaBlend( lighter - ? Colors.black.withOpacity(0.75) - : Colors.white.withOpacity(0.5), + ? Colors.black.withOpacity(0.675) + : Colors.white.withOpacity(0.675), accent); - final newColour = accent.atContrast(4.5, background, lighter); + accent = accent.atContrast(4.5, background, lighter); ref.read(playerScreenThemeProvider.notifier).state = ColorScheme.fromSwatch( - primarySwatch: generateMaterialColor(newColour), - accentColor: newColour, + primarySwatch: generateMaterialColor(accent), + accentColor: accent, brightness: theme.brightness, ); } diff --git a/lib/components/PlayerScreen/song_name_content.dart b/lib/components/PlayerScreen/song_name_content.dart index 6e30a139c..72e6a8df8 100644 --- a/lib/components/PlayerScreen/song_name_content.dart +++ b/lib/components/PlayerScreen/song_name_content.dart @@ -1,6 +1,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:finamp/components/PlayerScreen/player_buttons_more.dart'; -import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; import 'package:flutter/material.dart'; import '../favourite_button.dart'; @@ -10,49 +11,59 @@ import 'artist_chip.dart'; class SongNameContent extends StatelessWidget { const SongNameContent( {Key? key, - required this.songBaseItemDto, - required this.mediaItem, + required this.currentTrack, required this.separatedArtistTextSpans, required this.secondaryTextColour}) : super(key: key); - final BaseItemDto? songBaseItemDto; - final MediaItem mediaItem; + final FinampQueueItem currentTrack; final List separatedArtistTextSpans; final Color? secondaryTextColour; @override Widget build(BuildContext context) { + final jellyfin_models.BaseItemDto? songBaseItemDto = + currentTrack.item.extras!["itemJson"] != null + ? jellyfin_models.BaseItemDto.fromJson( + currentTrack.item.extras!["itemJson"]) + : null; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), - child: Text( - mediaItem.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 20, - height: 24 / 20, + padding: const EdgeInsets.only( + left: 10.0, right: 10.0, top: 4.0, bottom: 0.0), + child: Container( + height: 48.0, + alignment: Alignment.center, + child: Text( + currentTrack.item.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, + // height: 24 / 20, + ), + overflow: TextOverflow.ellipsis, + softWrap: true, + maxLines: 2, ), - overflow: TextOverflow.fade, - softWrap: true, - maxLines: 2, ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ - PlayerButtonsMore(), - Column( + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: ArtistChip( - item: songBaseItemDto, + PlayerButtonsMore(), + Flexible( + child: ArtistChips( + baseItem: songBaseItemDto, + color: IconTheme.of(context).color!.withOpacity(0.1), key: songBaseItemDto?.albumArtist == null ? null // We have to add -artist and -album to the keys because otherwise @@ -64,18 +75,23 @@ class SongNameContent extends StatelessWidget { : ValueKey("${songBaseItemDto!.albumArtist}-artist"), ), ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: AlbumChip( - item: songBaseItemDto, - key: songBaseItemDto?.album == null - ? null - : ValueKey("${songBaseItemDto!.album}-album"), - ), + FavoriteButton( + item: songBaseItemDto, + onToggle: (isFavorite) { + songBaseItemDto!.userData!.isFavorite = isFavorite; + currentTrack.item.extras!["itemJson"] = + songBaseItemDto.toJson(); + }, ), ], ), - FavoriteButton(item: songBaseItemDto), + AlbumChip( + item: songBaseItemDto, + color: IconTheme.of(context).color!.withOpacity(0.1), + key: songBaseItemDto?.album == null + ? null + : ValueKey("${songBaseItemDto!.album}-album"), + ), ], ), ), diff --git a/lib/components/album_image.dart b/lib/components/album_image.dart index 09280d1ce..e84d8c117 100644 --- a/lib/components/album_image.dart +++ b/lib/components/album_image.dart @@ -1,3 +1,4 @@ +import 'package:finamp/services/current_album_image_provider.dart'; import 'package:flutter/material.dart'; import 'package:octo_image/octo_image.dart'; diff --git a/lib/components/favourite_button.dart b/lib/components/favourite_button.dart index 194b68384..3f31cdbc8 100644 --- a/lib/components/favourite_button.dart +++ b/lib/components/favourite_button.dart @@ -7,14 +7,16 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; class FavoriteButton extends StatefulWidget { - const FavoriteButton( - {Key? key, - required this.item, - this.onlyIfFav = false, - this.inPlayer = false}) - : super(key: key); + const FavoriteButton({ + Key? key, + required this.item, + this.onToggle, + this.onlyIfFav = false, + this.inPlayer = false, + }) : super(key: key); final BaseItemDto? item; + final void Function(bool isFavorite)? onToggle; final bool onlyIfFav; final bool inPlayer; @@ -43,7 +45,7 @@ class _FavoriteButtonState extends State { return IconButton( icon: Icon( isFav ? Icons.favorite : Icons.favorite_outline, - color: isFav ? Colors.red : null, + color: isFav ? Theme.of(context).colorScheme.secondary : null, size: 24.0, ), tooltip: AppLocalizations.of(context)!.favourite, @@ -64,6 +66,10 @@ class _FavoriteButtonState extends State { widget.item!.toJson(); } }); + + if (widget.onToggle != null) { + widget.onToggle!(widget.item!.userData!.isFavorite); + } } catch (e) { errorSnackbar(e, context); } diff --git a/lib/components/finamp_app_bar_button.dart b/lib/components/finamp_app_bar_button.dart index 830ba9a2c..ec9ec1a6d 100644 --- a/lib/components/finamp_app_bar_button.dart +++ b/lib/components/finamp_app_bar_button.dart @@ -17,14 +17,14 @@ class FinampAppBarButton extends StatelessWidget { child: Container( width: kMinInteractiveDimension - 12, height: kMinInteractiveDimension - 12, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - shape: BoxShape.circle, - ), + // decoration: BoxDecoration( + // color: IconTheme.of(context).color?.withOpacity(0.1) ?? Colors.white.withOpacity(0.15), + // shape: BoxShape.circle, + // ), child: IconButton( onPressed: onPressed, tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: const FinampBackButtonIcon(), + icon: FinampBackButtonIcon(), // Needed because otherwise the splash goes over the container // It may be like a pixel over now but I've spent way too long on this diff --git a/lib/components/now_playing_bar.dart b/lib/components/now_playing_bar.dart index 07fa73deb..cbd3801c2 100644 --- a/lib/components/now_playing_bar.dart +++ b/lib/components/now_playing_bar.dart @@ -1,163 +1,502 @@ +import 'dart:math'; + import 'package:audio_service/audio_service.dart'; +import 'package:finamp/at_contrast.dart'; +import 'package:finamp/components/favourite_button.dart'; +import 'package:finamp/generate_material_color.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/services/current_album_image_provider.dart'; +import 'package:finamp/services/player_screen_theme_provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:finamp/services/theme_mode_helper.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:get_it/get_it.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:simple_gesture_detector/simple_gesture_detector.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; import '../services/finamp_settings_helper.dart'; import '../services/media_state_stream.dart'; import 'album_image.dart'; -import '../models/jellyfin_models.dart'; +import '../models/jellyfin_models.dart' as jellyfin_models; import '../services/process_artist.dart'; import '../services/music_player_background_task.dart'; import '../screens/player_screen.dart'; import 'PlayerScreen/progress_slider.dart'; -class NowPlayingBar extends StatelessWidget { +class NowPlayingBar extends ConsumerWidget { const NowPlayingBar({ Key? key, }) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { // BottomNavBar's default elevation is 8 (https://api.flutter.dev/flutter/material/BottomNavigationBar/elevation.html) + final imageTheme = ref.watch(playerScreenThemeProvider); + const elevation = 8.0; - final color = Theme.of(context).bottomNavigationBarTheme.backgroundColor; + const horizontalPadding = 8.0; + const albumImageSize = 70.0; + // final color = Theme.of(context).bottomNavigationBarTheme.backgroundColor; final audioHandler = GetIt.instance(); + final queueService = GetIt.instance(); - return SimpleGestureDetector( - onVerticalSwipe: (direction) { - if (direction == SwipeDirection.up) { - Navigator.of(context).pushNamed(PlayerScreen.routeName); - } - }, - child: StreamBuilder( - stream: mediaStateStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - final playing = snapshot.data!.playbackState.playing; - - // If we have a media item and the player hasn't finished, show - // the now playing bar. - if (snapshot.data!.mediaItem != null) { - final item = BaseItemDto.fromJson( - snapshot.data!.mediaItem!.extras!["itemJson"]); + Duration? playbackPosition; - return Material( - color: color, - elevation: elevation, - child: SafeArea( - child: SizedBox( - width: MediaQuery.of(context).size.width, - child: Stack( - children: [ - const ProgressSlider( - allowSeeking: false, - showBuffer: false, - showDuration: false, - showPlaceholder: false, - ), - Dismissible( - key: const Key("NowPlayingBar"), - direction: FinampSettingsHelper.finampSettings.disableGesture ? DismissDirection.none : DismissDirection.horizontal, - confirmDismiss: (direction) async { - if (direction == DismissDirection.endToStart) { - audioHandler.skipToNext(); - } else { - audioHandler.skipToPrevious(); - } - return false; - }, - background: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AspectRatio( - aspectRatio: 1, - child: FittedBox( - fit: BoxFit.fitHeight, - child: Padding( - padding: - EdgeInsets.symmetric(vertical: 8.0), - child: Icon(Icons.skip_previous), + return AnimatedTheme( + duration: const Duration(milliseconds: 500), + data: ThemeData( + fontFamily: "LexendDeca", + colorScheme: imageTheme?.copyWith( + brightness: Theme.of(context).brightness, + ), + iconTheme: Theme.of(context).iconTheme.copyWith( + color: imageTheme?.primary, + ), + ), + child: SimpleGestureDetector( + onVerticalSwipe: (direction) { + if (direction == SwipeDirection.up) { + Navigator.of(context).pushNamed(PlayerScreen.routeName); + } + }, + onTap: () => Navigator.of(context).pushNamed(PlayerScreen.routeName), + child: StreamBuilder( + stream: queueService.getQueueStream(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!.currentTrack != null) { + final currentTrack = snapshot.data!.currentTrack!; + final currentTrackBaseItem = + currentTrack.item.extras?["itemJson"] != null + ? jellyfin_models.BaseItemDto.fromJson(currentTrack + .item.extras!["itemJson"] as Map) + : null; + return Padding( + padding: const EdgeInsets.only( + left: 12.0, bottom: 12.0, right: 12.0), + child: Material( + shadowColor: Theme.of(context) + .colorScheme + .secondary + .withOpacity(0.2), + borderRadius: BorderRadius.circular(12.0), + clipBehavior: Clip.antiAlias, + color: Theme.of(context).brightness == Brightness.dark + ? IconTheme.of(context).color!.withOpacity(0.1) + : Theme.of(context).cardColor, + elevation: elevation, + child: SafeArea( + //TODO use a PageView instead of a Dismissible, and only wrap dynamic items (not the buttons) + child: Dismissible( + key: const Key("NowPlayingBar"), + direction: + FinampSettingsHelper.finampSettings.disableGesture + ? DismissDirection.none + : DismissDirection.horizontal, + confirmDismiss: (direction) async { + if (direction == DismissDirection.endToStart) { + audioHandler.skipToNext(); + } else { + audioHandler.skipToPrevious(); + } + return false; + }, + child: StreamBuilder( + stream: mediaStateStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + final playing = + snapshot.data!.playbackState.playing; + final mediaState = snapshot.data!; + // If we have a media item and the player hasn't finished, show + // the now playing bar. + if (snapshot.data!.mediaItem != null) { + //TODO move into separate component and share with queue list + return Container( + width: MediaQuery.of(context).size.width, + height: albumImageSize, + padding: EdgeInsets.zero, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: Color.alphaBlend( + Theme.of(context).brightness == + Brightness.dark + ? IconTheme.of(context) + .color! + .withOpacity(0.35) + : IconTheme.of(context) + .color! + .withOpacity(0.5), + Theme.of(context).brightness == + Brightness.dark + ? Colors.black + : Colors.white), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12.0)), + ), ), - ), - ), - AspectRatio( - aspectRatio: 1, - child: FittedBox( - fit: BoxFit.fitHeight, - child: Padding( - padding: - EdgeInsets.symmetric(vertical: 8.0), - child: Icon(Icons.skip_next), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + AlbumImage( + item: currentTrackBaseItem, + borderRadius: BorderRadius.zero, + itemsToPrecache: queueService + .getNextXTracksInQueue(3) + .map((e) { + final item = e.item.extras?[ + "itemJson"] != + null + ? jellyfin_models + .BaseItemDto.fromJson(e + .item + .extras!["itemJson"] + as Map) + : null; + return item!; + }).toList(), + ), + Container( + width: albumImageSize, + height: albumImageSize, + decoration: + const ShapeDecoration( + shape: Border(), + color: Color.fromRGBO( + 0, 0, 0, 0.3), + ), + child: IconButton( + onPressed: () { + Vibrate.feedback( + FeedbackType.success); + audioHandler + .togglePlayback(); + }, + icon: mediaState! + .playbackState.playing + ? const Icon( + TablerIcons + .player_pause, + size: 32, + ) + : const Icon( + TablerIcons + .player_play, + size: 32, + ), + color: Colors.white, + )), + ], + ), + Expanded( + child: Stack( + children: [ + Positioned( + left: 0, + top: 0, + child: StreamBuilder( + stream: AudioService + .position + .startWith(audioHandler + .playbackState + .value + .position), + builder: + (context, snapshot) { + if (snapshot.hasData) { + playbackPosition = + snapshot.data; + final screenSize = + 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 - + 2 * + horizontalPadding - + albumImageSize) * + (playbackPosition! + .inMilliseconds / + (mediaState.mediaItem + ?.duration ?? + const Duration( + seconds: 0)) + .inMilliseconds), + height: 70.0, + decoration: + ShapeDecoration( + color: IconTheme.of( + context) + .color! + .withOpacity( + 0.75), + shape: + const RoundedRectangleBorder( + borderRadius: + BorderRadius + .only( + topRight: Radius + .circular( + 12), + bottomRight: + Radius + .circular( + 12), + ), + ), + ), + ); + } else { + return Container(); + } + }), + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Expanded( + child: Container( + height: albumImageSize, + padding: + const EdgeInsets.only( + left: 12, + right: 4), + child: Column( + mainAxisSize: + MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment + .center, + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + currentTrack + .item.title, + style: const TextStyle( + color: Colors + .white, + fontSize: 16, + fontFamily: + 'Lexend Deca', + fontWeight: + FontWeight + .w500, + overflow: + TextOverflow + .ellipsis), + ), + const SizedBox( + height: 4), + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Expanded( + child: Text( + processArtist( + currentTrack! + .item + .artist, + context), + style: TextStyle( + color: Colors + .white + .withOpacity( + 0.85), + fontSize: + 13, + fontFamily: + 'Lexend Deca', + fontWeight: + FontWeight + .w300, + overflow: + TextOverflow + .ellipsis), + ), + ), + Row( + children: [ + StreamBuilder< + Duration>( + stream: AudioService.position.startWith(audioHandler + .playbackState + .value + .position), + builder: + (context, + snapshot) { + final TextStyle + style = + TextStyle( + color: Colors + .white + .withOpacity(0.8), + fontSize: + 14, + fontFamily: + 'Lexend Deca', + fontWeight: + FontWeight.w400, + ); + if (snapshot + .hasData) { + playbackPosition = + snapshot.data; + return Text( + // '0:00', + playbackPosition!.inHours >= 1.0 + ? "${playbackPosition?.inHours.toString()}:${((playbackPosition?.inMinutes ?? 0) % 60).toString().padLeft(2, '0')}:${((playbackPosition?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}" + : "${playbackPosition?.inMinutes.toString()}:${((playbackPosition?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}", + style: + style, + ); + } else { + return Text( + "0:00", + style: + style, + ); + } + }), + const SizedBox( + width: 2), + Text( + '/', + style: + TextStyle( + color: Colors + .white + .withOpacity( + 0.8), + fontSize: + 14, + fontFamily: + 'Lexend Deca', + fontWeight: + FontWeight + .w400, + ), + ), + const SizedBox( + width: 2), + Text( + // '3:44', + (mediaState.mediaItem?.duration?.inHours ?? + 0.0) >= + 1.0 + ? "${mediaState.mediaItem?.duration?.inHours.toString()}:${((mediaState.mediaItem?.duration?.inMinutes ?? 0) % 60).toString().padLeft(2, '0')}:${((mediaState.mediaItem?.duration?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}" + : "${mediaState.mediaItem?.duration?.inMinutes.toString()}:${((mediaState.mediaItem?.duration?.inSeconds ?? 0) % 60).toString().padLeft(2, '0')}", + style: + TextStyle( + color: Colors + .white + .withOpacity( + 0.8), + fontSize: + 14, + fontFamily: + 'Lexend Deca', + fontWeight: + FontWeight + .w400, + ), + ), + ], + ) + ], + ), + ], + ), + ), + ), + Row( + mainAxisAlignment: + MainAxisAlignment.end, + children: [ + Padding( + padding: + const EdgeInsets + .only( + top: 4.0, + right: 4.0), + child: FavoriteButton( + item: + currentTrackBaseItem, + onToggle: + (isFavorite) { + currentTrackBaseItem! + .userData! + .isFavorite = + isFavorite; + snapshot + .data! + .mediaItem + ?.extras![ + "itemJson"] = + currentTrackBaseItem + .toJson(); + }, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ], ), ), - ), - ], - ), - ), - child: ListTile( - onTap: () => Navigator.of(context) - .pushNamed(PlayerScreen.routeName), - leading: AlbumImage(item: item), - title: Text( - snapshot.data!.mediaItem!.title, - softWrap: false, - maxLines: 1, - overflow: TextOverflow.fade, - ), - subtitle: Text( - processArtist( - snapshot.data!.mediaItem!.artist, context), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (snapshot - .data!.playbackState.processingState != - AudioProcessingState.idle) - IconButton( - // We have a key here because otherwise the - // InkWell moves over to the play/pause button - key: const ValueKey("StopButton"), - icon: const Icon(Icons.stop), - onPressed: () => audioHandler.stop(), - ), - playing - ? IconButton( - icon: const Icon(Icons.pause), - onPressed: () => audioHandler.pause(), - ) - : IconButton( - icon: const Icon(Icons.play_arrow), - onPressed: () => audioHandler.play(), - ), - ], - ), - ), + ); + } else { + return const SizedBox( + width: 0, + height: 0, + ); + } + } else { + return const SizedBox( + width: 0, + height: 0, + ); + } + }, ), - ], + ), ), ), - ), - ); - } else { - return const SizedBox( - width: 0, - height: 0, - ); - } - } else { - return const SizedBox( - width: 0, - height: 0, - ); - } - }, + ); + } else { + return const SizedBox( + width: 0, + height: 0, + ); + } + }), ), ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5f88e39fc..7bcc46c61 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11,7 +11,7 @@ }, "serverUrl": "Server URL", "@serverUrl": {}, - "internalExternalIpExplanation": "If you want to be able to access your Jellyfin server remotely, you need to use your external IP.\n\nIf your server is on a HTTP port (80/443), you don't have to specify a port. This will likely be the case if your server is behind a reverse proxy.", + "internalExternalIpExplanation": "If you want to be able to access your Jellyfin server remotely, you need to use your external IP.\n\nIf your server is on a HTTP port (80/443), you don''t have to specify a port. This will likely be the case if your server is behind a reverse proxy.", "@internalExternalIpExplanation": { "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." }, @@ -296,7 +296,7 @@ "@selectDirectory": {}, "unknownError": "Unknown Error", "@unknownError": {}, - "pathReturnSlashErrorMessage": "Paths that return \"/\" can't be used", + "pathReturnSlashErrorMessage": "Paths that return \"/\" can''t be used", "@pathReturnSlashErrorMessage": {}, "directoryMustBeEmpty": "Directory must be empty", "@directoryMustBeEmpty": {}, @@ -404,6 +404,8 @@ "@noArtist": {}, "unknownArtist": "Unknown Artist", "@unknownArtist": {}, + "unknownAlbum": "Unknown Album", + "@unknownAlbum": {}, "streaming": "STREAMING", "@streaming": {}, "downloaded": "DOWNLOADED", @@ -480,5 +482,120 @@ "@bufferDuration": {}, "bufferDurationSubtitle": "How much the player should buffer, in seconds. Requires a restart.", "@bufferDurationSubtitle": {}, - "language": "Language" -} \ No newline at end of file + "language": "Language", + "@language": {}, + "previousTracks": "Previous Tracks", + "@previousTracks": { + "description": "Description in the queue panel for the list of tracks that come before the current track in the queue. The tracks might not actually have been played (e.g. if the user skipped ahead to a specific track)." + }, + "nextUp": "Next Up", + "@nextUp": { + "description": "Description in the queue panel for the list of tracks were manually added to be played after the current track. This should be capitalized (if applicable) to be more recognizable throughout the UI" + }, + "clearNextUp": "Clear Next Up", + "@clearNextUp": { + "description": "Label for the action that deletes all tracks added to Next Up" + }, + "playingFrom": "Playing from", + "@playingFrom": { + "description": "Prefix shown before the name of the main queue source, like the album or playlist that was used to start playback. Example: \"Playing from {My Nice Playlist}\"" + }, + "playNext": "Play next", + "@playNext": { + "description": "Used for adding a track to the \"Next Up\" queue at the first position, to play right after the current track finishes playing" + }, + "addToNextUp": "Add to Next Up", + "@addToNextUp": { + "description": "Used for adding a track to the \"Next Up\" queue at the end, to play after all prior tracks from Next Up have played " + }, + "shuffleNext": "Shuffle next", + "@shuffleNext": { + "description": "Used for shuffling a list (album, playlist, etc.) to the \"Next Up\" queue at the first position, to play right after the current track finishes playing" + }, + "shuffleToNextUp": "Shuffle to Next Up", + "@shuffleToNextUp": { + "description": "Used for shuffling a list (album, playlist, etc.) to the end of the \"Next Up\" queue, to play after all prior tracks from Next Up have played " + }, + "shuffleToQueue": "Shuffle to queue", + "@shuffleToQueue": { + "description": "Used for shuffling a list (album, playlist, etc.) to the end of the regular queue, to play after all prior tracks from the queue have played " + }, + "confirmPlayNext": "{type, select, track{Track} album{Album} artist{Artist} playlist{Playlist} other{Item}} will play next", + "@confirmPlayNext": { + "description": "A confirmation message that is shown after successfully adding a track to the front of the \"Next Up\" queue", + "placeholders": { + "type": { + "type": "String" + } + } + }, + "confirmAddToNextUp": "Added {type, select, track{track} album{album} artist{artist} playlist{playlist} other{item}} to Next Up", + "@confirmAddToNextUp": { + "description": "A confirmation message that is shown after successfully adding a track to the end of the \"Next Up\" queue", + "placeholders": { + "type": { + "type": "String" + } + } + }, + "confirmAddToQueue": "Added {type, select, track{track} album{album} artist{artist} playlist{playlist} other{item}} to queue", + "@confirmAddToQueue": { + "description": "A confirmation message that is shown after successfully adding a track to the end of the regular queue", + "placeholders": { + "type": { + "type": "String" + } + } + }, + "confirmShuffleNext": "Will shuffle next", + "@confirmShuffleNext": { + "description": "A confirmation message that is shown after successfully shuffling a list (album, playlist, etc.) to the front of the \"Next Up\" queue" + }, + "confirmShuffleToNextUp": "Shuffled to Next Up", + "@confirmShuffleToNextUp": { + "description": "A confirmation message that is shown after successfully shuffling a list (album, playlist, etc.) to the end of the \"Next Up\" queue" + }, + "confirmShuffleToQueue": "Shuffled to queue", + "@confirmShuffleToQueue": { + "description": "A confirmation message that is shown after successfully shuffling a list (album, playlist, etc.) to the end of the regular queue" + }, + "placeholderSource": "Somewhere", + "@placeholderSource": { + "description": "Placeholder text used when the source of the current track/queue is unknown" + }, + "playbackHistory": "Playback History", + "@playbackHistory": { + "description": "Title for the playback history screen, where the user can see a list of recently played tracks, sorted by " + }, + "yourLikes": "Your Likes", + "@yourLikes": { + "description": "Title for the queue source when the user is playing their liked tracks" + }, + "mix": "{mixSource} - Mix", + "@mix": { + "description": "Suffix added to a queue source when playing a mix. Example: \"Never Gonna Give You Up - Mix\"", + "placeholders": { + "mixSource": { + "type": "String", + "example": "Never Gonna Give You Up" + } + } + }, + "tracksFormerNextUp": "Tracks added via Next Up", + "@tracksFormerNextUp": { + "description": "Title for the queue source for tracks that were once added to the queue via the \"Next Up\" feature, but have since been played" + }, + "playingFromType": "Playing From {source, select, album{Album} playlist{Playlist} songMix{Song Mix} artistMix{Artist Mix} albumMix{Album Mix} favorites{Favorites} allSongs{All Songs} filteredList{Songs} genre{Genre} artist{Artist} nextUpAlbum{Album in Next Up} nextUpPlaylist{Playlist in Next Up} nextUpArtist{Artist in Next Up} other{}}", + "@playingFromType": { + "description": "Prefix shown before the type of the main queue source at the top of the player screen. Example: \"Playing From Album\"", + "placeholders": { + "source": { + "type": "String" + } + } + }, + "shuffleAllQueueSource": "Shuffle All", + "@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" + } +} diff --git a/lib/main.dart b/lib/main.dart index 7e027fa1f..6fa7602b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,13 +4,17 @@ import 'dart:ui'; 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/services/finamp_settings_helper.dart'; import 'package:finamp/services/finamp_user_helper.dart'; +import 'package:finamp/services/playback_history_service.dart'; +import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/date_symbol_data_local.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -62,7 +66,7 @@ void main() async { _setupJellyfinApiData(); await _setupDownloader(); await _setupDownloadsHelper(); - await _setupAudioServiceHelper(); + await _setupPlaybackServices(); } catch (e) { hasFailed = true; runApp(FinampErrorApp( @@ -83,6 +87,13 @@ void main() async { SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle(statusBarBrightness: Brightness.dark)); + final String localeString = (LocaleHelper.locale != null) + ? ((LocaleHelper.locale?.countryCode != null) + ? "${LocaleHelper.locale?.languageCode.toLowerCase()}_${LocaleHelper.locale?.countryCode?.toUpperCase()}" + : LocaleHelper.locale.toString()) + : "en_US"; + initializeDateFormatting(localeString, null); + runApp(const Finamp()); } } @@ -154,6 +165,7 @@ Future setupHive() async { Hive.registerAdapter(DownloadedImageAdapter()); Hive.registerAdapter(ThemeModeAdapter()); Hive.registerAdapter(LocaleAdapter()); + Hive.registerAdapter(FinampLoopModeAdapter()); await Future.wait([ Hive.openBox("DownloadedParents"), Hive.openBox("DownloadedItems"), @@ -178,7 +190,7 @@ Future setupHive() async { if (themeModeBox.isEmpty) ThemeModeHelper.setThemeMode(ThemeMode.system); } -Future _setupAudioServiceHelper() async { +Future _setupPlaybackServices() async { final session = await AudioSession.instance; session.configure(const AudioSessionConfiguration.music()); @@ -196,6 +208,8 @@ Future _setupAudioServiceHelper() async { // () async => ); GetIt.instance.registerSingleton(audioHandler); + GetIt.instance.registerSingleton(QueueService()); + GetIt.instance.registerSingleton(PlaybackHistoryService()); GetIt.instance.registerSingleton(AudioServiceHelper()); } @@ -298,6 +312,8 @@ class Finamp extends StatelessWidget { const DownloadsScreen(), DownloadsErrorScreen.routeName: (context) => const DownloadsErrorScreen(), + PlaybackHistoryScreen.routeName: (context) => + const PlaybackHistoryScreen(), LogsScreen.routeName: (context) => const LogsScreen(), SettingsScreen.routeName: (context) => const SettingsScreen(), @@ -334,7 +350,6 @@ class Finamp extends StatelessWidget { ), ), darkTheme: ThemeData( - brightness: Brightness.dark, scaffoldBackgroundColor: backgroundColor, appBarTheme: const AppBarTheme( color: raisedDarkColor, diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index 178f40624..2d29203ee 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -7,6 +8,7 @@ import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:uuid/uuid.dart'; import 'package:path/path.dart' as path_helper; +import 'package:audio_service/audio_service.dart'; import '../services/finamp_settings_helper.dart'; import 'jellyfin_models.dart'; @@ -52,7 +54,8 @@ const _sleepTimerSeconds = 1800; // 30 Minutes const _showCoverAsPlayerBackground = true; const _hideSongArtistsIfSameAsAlbumArtists = true; const _disableGesture = false; -const _bufferDurationSeconds = 50; +const _bufferDurationSeconds = 600; +const _defaultLoopMode = FinampLoopMode.all; @HiveType(typeId: 28) class FinampSettings { @@ -84,6 +87,7 @@ class FinampSettings { this.bufferDurationSeconds = _bufferDurationSeconds, required this.tabSortBy, required this.tabSortOrder, + this.loopMode = _defaultLoopMode, }); @HiveField(0) @@ -167,6 +171,9 @@ class FinampSettings { @HiveField(21, defaultValue: {}) Map tabSortOrder; + @HiveField(22, defaultValue: _defaultLoopMode) + FinampLoopMode loopMode; + static Future create() async { final internalSongDir = await getInternalSongDir(); final downloadLocation = DownloadLocation.create( @@ -554,3 +561,239 @@ class DownloadedImage { downloadLocationId: downloadLocationId, ); } + +@HiveType(typeId: 50) +enum FinampPlaybackOrder { + @HiveField(0) + shuffled, + @HiveField(1) + linear; +} + +@HiveType(typeId: 51) +enum FinampLoopMode { + @HiveField(0) + none, + @HiveField(1) + one, + @HiveField(2) + all; +} + +@HiveType(typeId: 52) +enum QueueItemSourceType { + @HiveField(0) + album, + @HiveField(1) + playlist, + @HiveField(2) + songMix, + @HiveField(3) + artistMix, + @HiveField(4) + albumMix, + @HiveField(5) + favorites, + @HiveField(6) + allSongs, + @HiveField(7) + filteredList, + @HiveField(8) + genre, + @HiveField(9) + artist, + @HiveField(10) + nextUp, + @HiveField(11) + nextUpAlbum, + @HiveField(12) + nextUpPlaylist, + @HiveField(13) + nextUpArtist, + @HiveField(14) + formerNextUp, + @HiveField(15) + downloads, + @HiveField(16) + unknown; +} + +@HiveType(typeId: 53) +enum QueueItemQueueType { + @HiveField(0) + previousTracks, + @HiveField(1) + currentTrack, + @HiveField(2) + nextUp, + @HiveField(3) + queue; +} + +@HiveType(typeId: 54) +class QueueItemSource { + QueueItemSource({ + required this.type, + required this.name, + required this.id, + this.item, + }); + + @HiveField(0) + QueueItemSourceType type; + + @HiveField(1) + QueueItemSourceName name; + + @HiveField(2) + String id; + + @HiveField(3) + BaseItemDto? item; +} + +@HiveType(typeId: 55) +enum QueueItemSourceNameType { + @HiveField(0) + preTranslated, + @HiveField(1) + yourLikes, + @HiveField(2) + shuffleAll, + @HiveField(3) + mix, + @HiveField(4) + instantMix, + @HiveField(5) + nextUp, + @HiveField(6) + tracksFormerNextUp, +} + +@HiveType(typeId: 56) +class QueueItemSourceName { + const QueueItemSourceName({ + required this.type, + this.pretranslatedName, + this.localizationParameter, // used if only part of the name is translated + }); + + @HiveField(0) + final QueueItemSourceNameType type; + @HiveField(1) + final String? pretranslatedName; + @HiveField(2) + final String? localizationParameter; + + getLocalized(BuildContext context) { + switch (type) { + case QueueItemSourceNameType.preTranslated: + return pretranslatedName ?? ""; + case QueueItemSourceNameType.yourLikes: + return AppLocalizations.of(context)!.yourLikes; + case QueueItemSourceNameType.shuffleAll: + return AppLocalizations.of(context)!.shuffleAllQueueSource; + case QueueItemSourceNameType.mix: + return AppLocalizations.of(context)!.mix(localizationParameter ?? ""); + case QueueItemSourceNameType.instantMix: + return AppLocalizations.of(context)!.instantMix; + case QueueItemSourceNameType.nextUp: + return AppLocalizations.of(context)!.nextUp; + case QueueItemSourceNameType.tracksFormerNextUp: + return AppLocalizations.of(context)!.tracksFormerNextUp; + } + } +} + +@HiveType(typeId: 57) +class FinampQueueItem { + FinampQueueItem({ + required this.item, + required this.source, + this.type = QueueItemQueueType.queue, + }) { + id = const Uuid().v4(); + } + + @HiveField(0) + late String id; + + @HiveField(1) + MediaItem item; + + @HiveField(2) + QueueItemSource source; + + @HiveField(3) + QueueItemQueueType type; +} + +@HiveType(typeId: 58) +class FinampQueueOrder { + FinampQueueOrder({ + required this.items, + required this.originalSource, + required this.linearOrder, + required this.shuffledOrder, + }); + + @HiveField(0) + List items; + + @HiveField(1) + QueueItemSource originalSource; + + /// The linear order of the items in the queue. Used when shuffle is disabled. + /// The integers at index x contains the index of the item within [items] at queue position x. + @HiveField(2) + List linearOrder; + + /// The shuffled order of the items in the queue. Used when shuffle is enabled. + /// The integers at index x contains the index of the item within [items] at queue position x. + @HiveField(3) + List shuffledOrder; +} + +@HiveType(typeId: 59) +class FinampQueueInfo { + FinampQueueInfo({ + required this.previousTracks, + required this.currentTrack, + required this.nextUp, + required this.queue, + required this.source, + }); + + @HiveField(0) + List previousTracks; + + @HiveField(1) + FinampQueueItem? currentTrack; + + @HiveField(2) + List nextUp; + + @HiveField(3) + List queue; + + @HiveField(4) + QueueItemSource source; +} + +@HiveType(typeId: 60) +class FinampHistoryItem { + FinampHistoryItem({ + required this.item, + required this.startTime, + this.endTime, + }); + + @HiveField(0) + FinampQueueItem item; + + @HiveField(1) + DateTime startTime; + + @HiveField(2) + DateTime? endTime; +} diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index a9935509e..4859314b5 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -92,20 +92,23 @@ class FinampSettingsAdapter extends TypeAdapter { fields[16] == null ? true : fields[16] as bool, hideSongArtistsIfSameAsAlbumArtists: fields[17] == null ? true : fields[17] as bool, - bufferDurationSeconds: fields[18] == null ? 50 : fields[18] as int, + bufferDurationSeconds: fields[18] == null ? 600 : fields[18] as int, tabSortBy: fields[20] == null ? {} : (fields[20] as Map).cast(), tabSortOrder: fields[21] == null ? {} : (fields[21] as Map).cast(), + loopMode: fields[22] == null + ? FinampLoopMode.all + : fields[22] as FinampLoopMode, )..disableGesture = fields[19] == null ? false : fields[19] as bool; } @override void write(BinaryWriter writer, FinampSettings obj) { writer - ..writeByte(22) + ..writeByte(23) ..writeByte(0) ..write(obj.isOffline) ..writeByte(1) @@ -149,7 +152,9 @@ class FinampSettingsAdapter extends TypeAdapter { ..writeByte(20) ..write(obj.tabSortBy) ..writeByte(21) - ..write(obj.tabSortOrder); + ..write(obj.tabSortOrder) + ..writeByte(22) + ..write(obj.loopMode); } @override @@ -353,6 +358,260 @@ class DownloadedImageAdapter extends TypeAdapter { typeId == other.typeId; } +class QueueItemSourceAdapter extends TypeAdapter { + @override + final int typeId = 54; + + @override + QueueItemSource read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return QueueItemSource( + type: fields[0] as QueueItemSourceType, + name: fields[1] as QueueItemSourceName, + id: fields[2] as String, + item: fields[3] as BaseItemDto?, + ); + } + + @override + void write(BinaryWriter writer, QueueItemSource obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.type) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.id) + ..writeByte(3) + ..write(obj.item); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueueItemSourceAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class QueueItemSourceNameAdapter extends TypeAdapter { + @override + final int typeId = 56; + + @override + QueueItemSourceName read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return QueueItemSourceName( + type: fields[0] as QueueItemSourceNameType, + pretranslatedName: fields[1] as String?, + localizationParameter: fields[2] as String?, + ); + } + + @override + void write(BinaryWriter writer, QueueItemSourceName obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.type) + ..writeByte(1) + ..write(obj.pretranslatedName) + ..writeByte(2) + ..write(obj.localizationParameter); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueueItemSourceNameAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class FinampQueueItemAdapter extends TypeAdapter { + @override + final int typeId = 57; + + @override + FinampQueueItem read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FinampQueueItem( + item: fields[1] as MediaItem, + source: fields[2] as QueueItemSource, + type: fields[3] as QueueItemQueueType, + )..id = fields[0] as String; + } + + @override + void write(BinaryWriter writer, FinampQueueItem obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.item) + ..writeByte(2) + ..write(obj.source) + ..writeByte(3) + ..write(obj.type); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampQueueItemAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class FinampQueueOrderAdapter extends TypeAdapter { + @override + final int typeId = 58; + + @override + FinampQueueOrder read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FinampQueueOrder( + items: (fields[0] as List).cast(), + originalSource: fields[1] as QueueItemSource, + linearOrder: (fields[2] as List).cast(), + shuffledOrder: (fields[3] as List).cast(), + ); + } + + @override + void write(BinaryWriter writer, FinampQueueOrder obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.items) + ..writeByte(1) + ..write(obj.originalSource) + ..writeByte(2) + ..write(obj.linearOrder) + ..writeByte(3) + ..write(obj.shuffledOrder); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampQueueOrderAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class FinampQueueInfoAdapter extends TypeAdapter { + @override + final int typeId = 59; + + @override + FinampQueueInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FinampQueueInfo( + previousTracks: (fields[0] as List).cast(), + currentTrack: fields[1] as FinampQueueItem?, + nextUp: (fields[2] as List).cast(), + queue: (fields[3] as List).cast(), + source: fields[4] as QueueItemSource, + ); + } + + @override + void write(BinaryWriter writer, FinampQueueInfo obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.previousTracks) + ..writeByte(1) + ..write(obj.currentTrack) + ..writeByte(2) + ..write(obj.nextUp) + ..writeByte(3) + ..write(obj.queue) + ..writeByte(4) + ..write(obj.source); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampQueueInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class FinampHistoryItemAdapter extends TypeAdapter { + @override + final int typeId = 60; + + @override + FinampHistoryItem read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FinampHistoryItem( + item: fields[0] as FinampQueueItem, + startTime: fields[1] as DateTime, + endTime: fields[2] as DateTime?, + ); + } + + @override + void write(BinaryWriter writer, FinampHistoryItem obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.item) + ..writeByte(1) + ..write(obj.startTime) + ..writeByte(2) + ..write(obj.endTime); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampHistoryItemAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + class TabContentTypeAdapter extends TypeAdapter { @override final int typeId = 36; @@ -446,6 +705,302 @@ class ContentViewTypeAdapter extends TypeAdapter { typeId == other.typeId; } +class FinampPlaybackOrderAdapter extends TypeAdapter { + @override + final int typeId = 50; + + @override + FinampPlaybackOrder read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return FinampPlaybackOrder.shuffled; + case 1: + return FinampPlaybackOrder.linear; + default: + return FinampPlaybackOrder.shuffled; + } + } + + @override + void write(BinaryWriter writer, FinampPlaybackOrder obj) { + switch (obj) { + case FinampPlaybackOrder.shuffled: + writer.writeByte(0); + break; + case FinampPlaybackOrder.linear: + writer.writeByte(1); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampPlaybackOrderAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class FinampLoopModeAdapter extends TypeAdapter { + @override + final int typeId = 51; + + @override + FinampLoopMode read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return FinampLoopMode.none; + case 1: + return FinampLoopMode.one; + case 2: + return FinampLoopMode.all; + default: + return FinampLoopMode.none; + } + } + + @override + void write(BinaryWriter writer, FinampLoopMode obj) { + switch (obj) { + case FinampLoopMode.none: + writer.writeByte(0); + break; + case FinampLoopMode.one: + writer.writeByte(1); + break; + case FinampLoopMode.all: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampLoopModeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class QueueItemSourceTypeAdapter extends TypeAdapter { + @override + final int typeId = 52; + + @override + QueueItemSourceType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return QueueItemSourceType.album; + case 1: + return QueueItemSourceType.playlist; + case 2: + return QueueItemSourceType.songMix; + case 3: + return QueueItemSourceType.artistMix; + case 4: + return QueueItemSourceType.albumMix; + case 5: + return QueueItemSourceType.favorites; + case 6: + return QueueItemSourceType.allSongs; + case 7: + return QueueItemSourceType.filteredList; + case 8: + return QueueItemSourceType.genre; + case 9: + return QueueItemSourceType.artist; + case 10: + return QueueItemSourceType.nextUp; + case 11: + return QueueItemSourceType.formerNextUp; + case 12: + return QueueItemSourceType.downloads; + case 13: + return QueueItemSourceType.unknown; + default: + return QueueItemSourceType.album; + } + } + + @override + void write(BinaryWriter writer, QueueItemSourceType obj) { + switch (obj) { + case QueueItemSourceType.album: + writer.writeByte(0); + break; + case QueueItemSourceType.playlist: + writer.writeByte(1); + break; + case QueueItemSourceType.songMix: + writer.writeByte(2); + break; + case QueueItemSourceType.artistMix: + writer.writeByte(3); + break; + case QueueItemSourceType.albumMix: + writer.writeByte(4); + break; + case QueueItemSourceType.favorites: + writer.writeByte(5); + break; + case QueueItemSourceType.allSongs: + writer.writeByte(6); + break; + case QueueItemSourceType.filteredList: + writer.writeByte(7); + break; + case QueueItemSourceType.genre: + writer.writeByte(8); + break; + case QueueItemSourceType.artist: + writer.writeByte(9); + break; + case QueueItemSourceType.nextUp: + writer.writeByte(10); + break; + case QueueItemSourceType.formerNextUp: + writer.writeByte(11); + break; + case QueueItemSourceType.downloads: + writer.writeByte(12); + break; + case QueueItemSourceType.unknown: + writer.writeByte(13); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueueItemSourceTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class QueueItemQueueTypeAdapter extends TypeAdapter { + @override + final int typeId = 53; + + @override + QueueItemQueueType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return QueueItemQueueType.previousTracks; + case 1: + return QueueItemQueueType.currentTrack; + case 2: + return QueueItemQueueType.nextUp; + case 3: + return QueueItemQueueType.queue; + default: + return QueueItemQueueType.previousTracks; + } + } + + @override + void write(BinaryWriter writer, QueueItemQueueType obj) { + switch (obj) { + case QueueItemQueueType.previousTracks: + writer.writeByte(0); + break; + case QueueItemQueueType.currentTrack: + writer.writeByte(1); + break; + case QueueItemQueueType.nextUp: + writer.writeByte(2); + break; + case QueueItemQueueType.queue: + writer.writeByte(3); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueueItemQueueTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class QueueItemSourceNameTypeAdapter + extends TypeAdapter { + @override + final int typeId = 55; + + @override + QueueItemSourceNameType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return QueueItemSourceNameType.preTranslated; + case 1: + return QueueItemSourceNameType.yourLikes; + case 2: + return QueueItemSourceNameType.shuffleAll; + case 3: + return QueueItemSourceNameType.mix; + case 4: + return QueueItemSourceNameType.instantMix; + case 5: + return QueueItemSourceNameType.nextUp; + case 6: + return QueueItemSourceNameType.tracksFormerNextUp; + default: + return QueueItemSourceNameType.preTranslated; + } + } + + @override + void write(BinaryWriter writer, QueueItemSourceNameType obj) { + switch (obj) { + case QueueItemSourceNameType.preTranslated: + writer.writeByte(0); + break; + case QueueItemSourceNameType.yourLikes: + writer.writeByte(1); + break; + case QueueItemSourceNameType.shuffleAll: + writer.writeByte(2); + break; + case QueueItemSourceNameType.mix: + writer.writeByte(3); + break; + case QueueItemSourceNameType.instantMix: + writer.writeByte(4); + break; + case QueueItemSourceNameType.nextUp: + writer.writeByte(5); + break; + case QueueItemSourceNameType.tracksFormerNextUp: + writer.writeByte(6); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueueItemSourceNameTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** diff --git a/lib/screens/blurred_player_screen_background.dart b/lib/screens/blurred_player_screen_background.dart new file mode 100644 index 000000000..be39b6035 --- /dev/null +++ b/lib/screens/blurred_player_screen_background.dart @@ -0,0 +1,50 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:octo_image/octo_image.dart'; + +import '../services/current_album_image_provider.dart'; + +/// Same as [_PlayerScreenAlbumImage], but with a BlurHash instead. We also +/// filter the BlurHash so that it works as a background image. +class BlurredPlayerScreenBackground extends ConsumerWidget { + /// should never be less than 1.0 + final double brightnessFactor; + + const BlurredPlayerScreenBackground({ + Key? key, + this.brightnessFactor = 1.0, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final imageProvider = ref.watch(currentAlbumImageProvider); + + return ClipRect( + child: imageProvider == null + ? const SizedBox.shrink() + : OctoImage( + image: imageProvider, + fit: BoxFit.cover, + placeholderBuilder: (_) => const SizedBox.shrink(), + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + imageBuilder: (context, child) => ColorFiltered( + colorFilter: ColorFilter.mode( + Theme.of(context).brightness == Brightness.dark + ? Colors.black.withOpacity(0.675 / brightnessFactor) + : Colors.white.withOpacity(0.5 / brightnessFactor), + BlendMode.srcOver), + child: ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 85, + sigmaY: 85, + tileMode: TileMode.mirror, + ), + child: SizedBox.expand(child: child), + ), + ), + ), + ); + } +} diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart index 0743558c4..53da5c6a9 100644 --- a/lib/screens/music_screen.dart +++ b/lib/screens/music_screen.dart @@ -1,5 +1,7 @@ +import 'package:finamp/screens/playback_history_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:get_it/get_it.dart'; import 'package:hive/hive.dart'; import 'package:logging/logging.dart'; @@ -76,6 +78,7 @@ class _MusicScreenState extends State .where((element) => element.value) .length, vsync: this, + initialIndex: ModalRoute.of(context)?.settings.arguments as int? ?? 0, ); _tabController!.addListener(_tabIndexCallback); @@ -84,7 +87,6 @@ class _MusicScreenState extends State @override void initState() { super.initState(); - _buildTabController(); } @override @@ -119,13 +121,14 @@ class _MusicScreenState extends State tooltip: AppLocalizations.of(context)!.startMix, onPressed: () async { try { - if (_jellyfinApiHelper.selectedMixArtistsIds.isEmpty) { + if (_jellyfinApiHelper.selectedMixArtists.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( AppLocalizations.of(context)!.startMixNoSongsArtist))); } else { await _audioServiceHelper.startInstantMixForArtists( - _jellyfinApiHelper.selectedMixArtistsIds); + _jellyfinApiHelper.selectedMixArtists); + _jellyfinApiHelper.clearArtistMixBuilderList(); } } catch (e) { errorSnackbar(e, context); @@ -138,13 +141,13 @@ class _MusicScreenState extends State tooltip: AppLocalizations.of(context)!.startMix, onPressed: () async { try { - if (_jellyfinApiHelper.selectedMixAlbumIds.isEmpty) { + if (_jellyfinApiHelper.selectedMixAlbums.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( AppLocalizations.of(context)!.startMixNoSongsAlbum))); } else { await _audioServiceHelper.startInstantMixForAlbums( - _jellyfinApiHelper.selectedMixAlbumIds); + _jellyfinApiHelper.selectedMixAlbums); } } catch (e) { errorSnackbar(e, context); @@ -158,6 +161,10 @@ class _MusicScreenState extends State @override Widget build(BuildContext context) { + if (_tabController == null) { + _buildTabController(); + } + return ValueListenableBuilder>( valueListenable: _finampUserHelper.finampUsersListenable, builder: (context, value, _) { @@ -231,6 +238,12 @@ class _MusicScreenState extends State ) ] : [ + IconButton( + icon: const Icon(TablerIcons.clock), + onPressed: () => Navigator.of(context) + .pushNamed(PlaybackHistoryScreen.routeName), + tooltip: "Playback History", + ), SortOrderButton( tabs.elementAt(_tabController!.index), ), diff --git a/lib/screens/playback_history_screen.dart b/lib/screens/playback_history_screen.dart new file mode 100644 index 000000000..e5a5d896c --- /dev/null +++ b/lib/screens/playback_history_screen.dart @@ -0,0 +1,34 @@ +import 'package:finamp/components/PlaybackHistoryScreen/playback_history_list.dart'; +import 'package:finamp/components/now_playing_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/finamp_app_bar_button.dart'; + +class PlaybackHistoryScreen extends StatelessWidget { + const PlaybackHistoryScreen({Key? key}) : super(key: key); + + static const routeName = "/playbackhistory"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + elevation: 0.0, + leadingWidth: 48 + 24, + toolbarHeight: 75.0, + backgroundColor: Colors.transparent, + title: Text(AppLocalizations.of(context)!.playbackHistory), + leading: FinampAppBarButton( + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: const Padding( + padding: EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0, bottom: 0.0), + child: PlaybackHistoryList(), + ), + bottomNavigationBar: const NowPlayingBar(), + ); + } +} diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index 16d773558..ae5e1e06c 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:finamp/components/PlayerScreen/player_screen_appbar_title.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:octo_image/octo_image.dart'; @@ -12,7 +13,12 @@ import '../components/finamp_app_bar_button.dart'; import '../components/PlayerScreen/queue_list.dart'; import '../services/current_album_image_provider.dart'; import '../services/finamp_settings_helper.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:get_it/get_it.dart'; +import '../models/finamp_models.dart'; + import '../services/player_screen_theme_provider.dart'; +import 'blurred_player_screen_background.dart'; const _toolbarHeight = 75.0; @@ -25,6 +31,29 @@ class PlayerScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final imageTheme = ref.watch(playerScreenThemeProvider); + return AnimatedTheme( + duration: const Duration(milliseconds: 500), + data: ThemeData( + fontFamily: "LexendDeca", + colorScheme: imageTheme?.copyWith( + brightness: Theme.of(context).brightness, + ), + iconTheme: Theme.of(context).iconTheme.copyWith( + color: imageTheme?.primary, + ), + ), + child: const _PlayerScreenContent(), + ); + } +} + +class _PlayerScreenContent extends StatelessWidget { + const _PlayerScreenContent({ + super.key, + }); + + @override + Widget build(BuildContext context) { return SimpleGestureDetector( onVerticalSwipe: (direction) { if (!FinampSettingsHelper.finampSettings.disableGesture) { @@ -35,135 +64,34 @@ class PlayerScreen extends ConsumerWidget { } } }, - child: Theme( - data: ThemeData( - fontFamily: "LexendDeca", - colorScheme: imageTheme, - brightness: Theme.of(context).brightness, - iconTheme: Theme.of(context).iconTheme.copyWith( - color: imageTheme?.primary, - ), + child: Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + leadingWidth: 48 + 24, + toolbarHeight: _toolbarHeight, + title: const PlayerScreenAppBarTitle(), + leading: FinampAppBarButton( + onPressed: () => Navigator.of(context).pop(), + ), ), - child: Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - centerTitle: true, - leadingWidth: 48 + 24, - toolbarHeight: _toolbarHeight, - // actions: const [ - // SleepTimerButton(), - // AddToPlaylistButton(), - // ], - // title: Baseline( - // baselineType: TextBaseline.alphabetic, - // baseline: 0, - // child: Text.rich( - // textAlign: TextAlign.center, - // TextSpan( - // style: GoogleFonts.montserrat(), - // children: [ - // TextSpan( - // text: "Playing From\n", - // style: TextStyle( - // fontSize: 12, - // color: Colors.white.withOpacity(0.7), - // height: 3), - // ), - // const TextSpan( - // text: "Your Likes", - // style: TextStyle( - // fontSize: 16, - // color: Colors.white, - // ), - // ) - // ], - // ), - // ), - // ), - title: Baseline( - baselineType: TextBaseline.alphabetic, - baseline: 0, + // Required for sleep timer input + resizeToAvoidBottomInset: false, extendBodyBehindAppBar: true, + body: Stack( + children: [ + if (FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground) + const BlurredPlayerScreenBackground(), + const SafeArea( + minimum: EdgeInsets.only(top: _toolbarHeight), child: Column( - children: [ - Text( - "Playing From", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w300, - color: Colors.white.withOpacity(0.7), - ), - ), - const Padding(padding: EdgeInsets.symmetric(vertical: 2)), - const Text( - "Somewhere", - style: TextStyle( - fontSize: 16, - color: Colors.white, - ), - ), - ], + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [SongInfo(), ControlArea(), QueueButton()], ), ), - leading: FinampAppBarButton( - onPressed: () => Navigator.of(context).pop(), - ), - ), - // Required for sleep timer input - resizeToAvoidBottomInset: false, extendBodyBehindAppBar: true, - body: Stack( - children: [ - if (FinampSettingsHelper - .finampSettings.showCoverAsPlayerBackground) - const _BlurredPlayerScreenBackground(), - const SafeArea( - minimum: EdgeInsets.only(top: _toolbarHeight), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [SongInfo(), ControlArea(), QueueButton()], - ), - ), - ], - ), + ], ), ), ); } } - -/// Same as [_PlayerScreenAlbumImage], but with a BlurHash instead. We also -/// filter the BlurHash so that it works as a background image. -class _BlurredPlayerScreenBackground extends ConsumerWidget { - const _BlurredPlayerScreenBackground({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final imageProvider = ref.watch(currentAlbumImageProvider); - - return ClipRect( - child: imageProvider == null - ? const SizedBox.shrink() - : OctoImage( - image: imageProvider, - fit: BoxFit.cover, - placeholderBuilder: (_) => const SizedBox.shrink(), - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - imageBuilder: (context, child) => ColorFiltered( - colorFilter: ColorFilter.mode( - Theme.of(context).brightness == Brightness.dark - ? Colors.black.withOpacity(0.75) - : Colors.white.withOpacity(0.50), - BlendMode.srcOver), - child: ImageFiltered( - imageFilter: ImageFilter.blur( - sigmaX: 85, - sigmaY: 85, - tileMode: TileMode.mirror, - ), - child: SizedBox.expand(child: child), - ), - ), - ), - ); - } -} diff --git a/lib/services/audio_service_helper.dart b/lib/services/audio_service_helper.dart index 989ee97a3..a8e847ebd 100644 --- a/lib/services/audio_service_helper.dart +++ b/lib/services/audio_service_helper.dart @@ -1,89 +1,33 @@ +import 'dart:collection'; + import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'finamp_user_helper.dart'; import 'jellyfin_api_helper.dart'; import 'finamp_settings_helper.dart'; import 'downloads_helper.dart'; -import '../models/jellyfin_models.dart'; +import '../models/finamp_models.dart'; +import '../models/jellyfin_models.dart' as jellyfin_models; import 'music_player_background_task.dart'; +import 'queue_service.dart'; /// Just some functions to make talking to AudioService a bit neater. class AudioServiceHelper { final _jellyfinApiHelper = GetIt.instance(); final _downloadsHelper = GetIt.instance(); - final _audioHandler = GetIt.instance(); + final _queueService = GetIt.instance(); final _finampUserHelper = GetIt.instance(); final audioServiceHelperLogger = Logger("AudioServiceHelper"); - /// Replaces the queue with the given list of items. If startAtIndex is specified, Any items below it - /// will be ignored. This is used for when the user taps in the middle of an album to start from that point. - Future replaceQueueWithItem({ - required List itemList, - int initialIndex = 0, - bool shuffle = false, - }) async { - try { - if (initialIndex > itemList.length) { - return Future.error( - "startAtIndex is bigger than the itemList! ($initialIndex > ${itemList.length})"); - } - - List queue = []; - for (BaseItemDto item in itemList) { - try { - queue.add(await _generateMediaItem(item)); - } catch (e) { - audioServiceHelperLogger.severe(e); - } - } - - // if (!shuffle) { - // // Give the audio service our next initial index so that playback starts - // // at that index. We don't do this if shuffling because it causes the - // // queue to always start at the start (although you could argue that we - // // still should if initialIndex is not 0, but that doesn't happen - // // anywhere in this app so oh well). - _audioHandler.setNextInitialIndex(initialIndex); - // } - - await _audioHandler.updateQueue(queue); - - if (shuffle) { - await _audioHandler.setShuffleMode(AudioServiceShuffleMode.all); - } else { - await _audioHandler.setShuffleMode(AudioServiceShuffleMode.none); - } - - _audioHandler.play(); - } catch (e) { - audioServiceHelperLogger.severe(e); - return Future.error(e); - } - } - - Future addQueueItem(BaseItemDto item) 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]); - return; - } - - final itemMediaItem = await _generateMediaItem(item); - await _audioHandler.addQueueItem(itemMediaItem); - } catch (e) { - audioServiceHelperLogger.severe(e); - return Future.error(e); - } - } - /// Shuffles every song in the user's current view. Future shuffleAll(bool isFavourite) async { - List? items; + List? items; if (FinampSettingsHelper.finampSettings.isOffline) { // If offline, get a shuffled list of songs from _downloadsHelper. @@ -110,18 +54,45 @@ class AudioServiceHelper { } if (items != null) { - await replaceQueueWithItem(itemList: items, shuffle: true); + await _queueService.startPlayback( + items: items, + source: QueueItemSource( + type: isFavourite + ? QueueItemSourceType.favorites + : QueueItemSourceType.allSongs, + name: QueueItemSourceName( + type: isFavourite + ? QueueItemSourceNameType.yourLikes + : QueueItemSourceNameType.shuffleAll, + ), + id: "shuffleAll", + ), + order: FinampPlaybackOrder.shuffled, + ); } } /// Start instant mix from item. - Future startInstantMixForItem(BaseItemDto item) async { - List? items; + Future startInstantMixForItem(jellyfin_models.BaseItemDto item) async { + List? items; try { items = await _jellyfinApiHelper.getInstantMix(item); if (items != null) { - await replaceQueueWithItem(itemList: items, shuffle: false); + await _queueService.startPlayback( + items: items, + source: QueueItemSource( + type: QueueItemSourceType.songMix, + name: QueueItemSourceName( + type: item.name != null + ? QueueItemSourceNameType.mix + : QueueItemSourceNameType.instantMix, + localizationParameter: item.name ?? "", + ), + id: item.id), + order: FinampPlaybackOrder + .linear, // instant mixes should have their order determined by the server, especially to make sure the first item is the one that the mix is based off of + ); } } catch (e) { audioServiceHelperLogger.severe(e); @@ -130,13 +101,27 @@ class AudioServiceHelper { } /// Start instant mix from a selection of artists. - Future startInstantMixForArtists(List artistIds) async { - List? items; + Future startInstantMixForArtists(List artists) async { + List? items; try { - items = await _jellyfinApiHelper.getArtistMix(artistIds); + items = await _jellyfinApiHelper + .getArtistMix(artists.map((e) => e.id).toList()); if (items != null) { - await replaceQueueWithItem(itemList: items, shuffle: false); + await _queueService.startPlayback( + items: items, + source: QueueItemSource( + type: QueueItemSourceType.artistMix, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: artists.map((e) => e.name).join(" & ")), + id: artists.first.id, + item: artists.first, + ), + order: FinampPlaybackOrder + .linear, // instant mixes should have their order determined by the server, especially to make sure the first item is the one that the mix is based off of + ); + _jellyfinApiHelper.clearArtistMixBuilderList(); } } catch (e) { audioServiceHelperLogger.severe(e); @@ -145,51 +130,31 @@ class AudioServiceHelper { } /// Start instant mix from a selection of albums. - Future startInstantMixForAlbums(List albumIds) async { - List? items; + Future startInstantMixForAlbums(List albums) async { + List? items; try { - items = await _jellyfinApiHelper.getAlbumMix(albumIds); + items = await _jellyfinApiHelper + .getAlbumMix(albums.map((e) => e.id).toList()); if (items != null) { - await replaceQueueWithItem(itemList: items, shuffle: false); + await _queueService.startPlayback( + items: items, + source: QueueItemSource( + type: QueueItemSourceType.albumMix, + name: QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated, + pretranslatedName: albums.map((e) => e.name).join(" & ")), + id: albums.first.id, + item: albums.first, + ), + order: FinampPlaybackOrder + .linear, // instant mixes should have their order determined by the server, especially to make sure the first item is the one that the mix is based off of + ); + _jellyfinApiHelper.clearAlbumMixBuilderList(); } } catch (e) { audioServiceHelperLogger.severe(e); return Future.error(e); } } - - Future _generateMediaItem(BaseItemDto item) async { - const uuid = Uuid(); - - final downloadedSong = _downloadsHelper.getDownloadedSong(item.id); - final isDownloaded = downloadedSong == null - ? false - : await _downloadsHelper.verifyDownloadedSong(downloadedSong); - - return MediaItem( - id: uuid.v4(), - album: item.album ?? "Unknown Album", - artist: item.artists?.join(", ") ?? item.albumArtist, - artUri: _downloadsHelper.getDownloadedImage(item)?.file.uri ?? - _jellyfinApiHelper.getImageUrl(item: item), - title: item.name ?? "Unknown Name", - extras: { - // "parentId": item.parentId, - // "itemId": item.id, - "itemJson": item.toJson(), - "shouldTranscode": FinampSettingsHelper.finampSettings.shouldTranscode, - "downloadedSongJson": isDownloaded - ? (_downloadsHelper.getDownloadedSong(item.id))!.toJson() - : null, - "isOffline": FinampSettingsHelper.finampSettings.isOffline, - // TODO: Maybe add transcoding bitrate here? - }, - // Jellyfin returns microseconds * 10 for some reason - duration: Duration( - microseconds: - (item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10), - ), - ); - } } diff --git a/lib/services/finamp_settings_helper.dart b/lib/services/finamp_settings_helper.dart index 696a70dbc..b403b4985 100644 --- a/lib/services/finamp_settings_helper.dart +++ b/lib/services/finamp_settings_helper.dart @@ -1,3 +1,4 @@ +import 'package:finamp/services/queue_service.dart'; import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -189,4 +190,12 @@ class FinampSettingsHelper { Hive.box("FinampSettings") .put("FinampSettings", finampSettingsTemp); } + + /// Set the loopMode property + static void setLoopMode(FinampLoopMode loopMode) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.loopMode = loopMode; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } } diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart index fca6fee19..e3f841ece 100644 --- a/lib/services/jellyfin_api.dart +++ b/lib/services/jellyfin_api.dart @@ -359,6 +359,9 @@ abstract class JellyfinApi extends ChopperService { Future logout(); static JellyfinApi create() { + final chopperHttpLogLevel = Level + .body; //TODO allow changing the log level in settings (and a debug config file?) + final client = ChopperClient( // The first part of the URL is now here services: [ @@ -413,7 +416,7 @@ abstract class JellyfinApi extends ChopperService { // return request.copyWith( // headers: {"X-Emby-Authentication": await getAuthHeader()}); // }, - HttpLoggingInterceptor(), + HttpLoggingInterceptor(level: chopperHttpLogLevel), ], ); diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart index e55504c99..d2a774b00 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -12,10 +12,10 @@ class JellyfinApiHelper { final _jellyfinApiHelperLogger = Logger("JellyfinApiHelper"); // Stores the ids of the artists that the user selected to mix - List selectedMixArtistsIds = []; + List selectedMixArtists = []; // Stores the ids of albums that the user selected to mix - List selectedMixAlbumIds = []; + List selectedMixAlbums = []; Uri? baseUrlTemp; @@ -349,19 +349,27 @@ class JellyfinApiHelper { } void addArtistToMixBuilderList(BaseItemDto item) { - selectedMixArtistsIds.add(item.id); + selectedMixArtists.add(item); } - void removeArtistFromBuilderList(BaseItemDto item) { - selectedMixArtistsIds.remove(item.id); + void removeArtistFromMixBuilderList(BaseItemDto item) { + selectedMixArtists.remove(item); + } + + void clearArtistMixBuilderList() { + selectedMixArtists.clear(); } void addAlbumToMixBuilderList(BaseItemDto item) { - selectedMixAlbumIds.add(item.id); + selectedMixAlbums.add(item); + } + + void removeAlbumFromMixBuilderList(BaseItemDto item) { + selectedMixAlbums.remove(item); } - void removeAlbumFromBuilderList(BaseItemDto item) { - selectedMixAlbumIds.remove(item.id); + void clearAlbumMixBuilderList() { + selectedMixAlbums.clear(); } Future?> getArtistMix(List artistIds) async { diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 8d6227dc3..83578c92c 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -1,19 +1,11 @@ import 'dart:async'; -import 'dart:io'; -import 'package:android_id/android_id.dart'; import 'package:audio_service/audio_service.dart'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; -import 'package:get_it/get_it.dart'; import 'package:just_audio/just_audio.dart'; import 'package:logging/logging.dart'; -import '../models/finamp_models.dart'; -import '../models/jellyfin_models.dart'; import 'finamp_settings_helper.dart'; -import 'finamp_user_helper.dart'; -import 'jellyfin_api_helper.dart'; /// This provider handles the currently playing music so that multiple widgets /// can control music. @@ -22,7 +14,9 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { audioLoadConfiguration: AudioLoadConfiguration( androidLoadControl: AndroidLoadControl( minBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration, - maxBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration, + maxBufferDuration: FinampSettingsHelper + .finampSettings.bufferDuration * + 1.5, // allows the player to fetch a bit more data in exchange for reduced request frequency prioritizeTimeOverSizeThresholds: true, ), darwinLoadControl: DarwinLoadControl( @@ -33,24 +27,11 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { ConcatenatingAudioSource _queueAudioSource = ConcatenatingAudioSource(children: []); final _audioServiceBackgroundTaskLogger = Logger("MusicPlayerBackgroundTask"); - final _jellyfinApiHelper = GetIt.instance(); - final _finampUserHelper = GetIt.instance(); - - /// Set when shuffle mode is changed. If true, [onUpdateQueue] will create a - /// shuffled [ConcatenatingAudioSource]. - bool shuffleNextQueue = false; /// Set when creating a new queue. Will be used to set the first index in a /// new queue. int? nextInitialIndex; - /// The item that was previously played. Used for reporting playback status. - MediaItem? _previousItem; - - /// Set to true when we're stopping the audio service. Used to avoid playback - /// progress reporting. - bool _isStopping = false; - /// Holds the current sleep timer, if any. This is a ValueNotifier so that /// widgets like SleepTimerButton can update when the sleep timer is/isn't /// null. @@ -58,6 +39,8 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { Duration _sleepTimerDuration = Duration.zero; final ValueNotifier _sleepTimer = ValueNotifier(null); + Future Function()? _queueCallbackPreviousTrack; + List? get shuffleIndices => _player.shuffleIndices; ValueListenable get sleepTimer => _sleepTimer; @@ -68,16 +51,6 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { // Propagate all events from the audio player to AudioService clients. _player.playbackEventStream.listen((event) async { playbackState.add(_transformEvent(event)); - - if (playbackState.valueOrNull != null && - playbackState.valueOrNull?.processingState != - AudioProcessingState.idle && - playbackState.valueOrNull?.processingState != - AudioProcessingState.completed && - !FinampSettingsHelper.finampSettings.isOffline && - !_isStopping) { - await _updatePlaybackProgress(); - } }); // Special processing for state transitions. @@ -87,45 +60,45 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } }); - _player.currentIndexStream.listen((event) async { - if (event == null) return; - - final currentItem = _getQueueItem(event); - mediaItem.add(currentItem); - - if (!FinampSettingsHelper.finampSettings.isOffline) { - final jellyfinApiHelper = GetIt.instance(); - - if (_previousItem != null) { - final playbackData = generatePlaybackProgressInfo( - item: _previousItem, - includeNowPlayingQueue: true, - ); - - if (playbackData != null) { - await jellyfinApiHelper.stopPlaybackProgress(playbackData); - } - } - - final playbackData = generatePlaybackProgressInfo( - item: currentItem, - includeNowPlayingQueue: true, - ); + // PlaybackEvent doesn't include shuffle/loops so we listen for changes here + _player.shuffleModeEnabledStream.listen((_) { + final event = _transformEvent(_player.playbackEvent); + playbackState.add(event); + _audioServiceBackgroundTaskLogger.info( + "Shuffle mode changed to ${event.shuffleMode} (${_player.shuffleModeEnabled})."); + }); + _player.loopModeStream.listen((_) { + final event = _transformEvent(_player.playbackEvent); + playbackState.add(event); + _audioServiceBackgroundTaskLogger.info( + "Loop mode changed to ${event.repeatMode} (${_player.loopMode})."); + }); + } - if (playbackData != null) { - await jellyfinApiHelper.reportPlaybackStart(playbackData); - } + /// this could be useful for updating queue state from this player class, but isn't used right now due to limitations with just_audio + void setQueueCallbacks({ + required Future Function() previousTrackCallback, + }) { + _queueCallbackPreviousTrack = previousTrackCallback; + } - // Set item for next index update - _previousItem = currentItem; - } - }); + Future initializeAudioSource(ConcatenatingAudioSource source) async { + _queueAudioSource = source; - // PlaybackEvent doesn't include shuffle/loops so we listen for changes here - _player.shuffleModeEnabledStream.listen( - (_) => playbackState.add(_transformEvent(_player.playbackEvent))); - _player.loopModeStream.listen( - (_) => playbackState.add(_transformEvent(_player.playbackEvent))); + try { + await _player.setAudioSource( + _queueAudioSource, + initialIndex: nextInitialIndex, + ); + } on PlayerException catch (e) { + _audioServiceBackgroundTaskLogger + .severe("Player error code ${e.code}: ${e.message}"); + } on PlayerInterruptedException catch (e) { + _audioServiceBackgroundTaskLogger + .warning("Player interrupted: ${e.message}"); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe("Player error ${e.toString()}"); + } } @override @@ -147,30 +120,28 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { @override Future pause() => _player.pause(); + Future togglePlayback() { + if (_player.playing) { + return pause(); + } else { + return play(); + } + } + @override Future stop() async { try { _audioServiceBackgroundTaskLogger.info("Stopping audio service"); - _isStopping = true; - - // Clear the previous item. - _previousItem = null; - - // Tell Jellyfin we're no longer playing audio if we're online - if (!FinampSettingsHelper.finampSettings.isOffline) { - final playbackInfo = - generatePlaybackProgressInfo(includeNowPlayingQueue: false); - if (playbackInfo != null) { - await _jellyfinApiHelper.stopPlaybackProgress(playbackInfo); - } - } - // Stop playing audio. await _player.stop(); - // Seek to the start of the first item in the queue - await _player.seek(Duration.zero, index: 0); + mediaItem.add(null); + playbackState.add(playbackState.value + .copyWith(processingState: AudioProcessingState.completed)); + + // // Seek to the start of the current item + await _player.seek(Duration.zero); _sleepTimerIsSet = false; _sleepTimerDuration = Duration.zero; @@ -179,89 +150,42 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { _sleepTimer.value = null; await super.stop(); - - // await _player.dispose(); - // await _eventSubscription?.cancel(); - // // It is important to wait for this state to be broadcast before we shut - // // down the task. If we don't, the background task will be destroyed before - // // the message gets sent to the UI. - // await _broadcastState(); - // // Shut down this background task - // await super.stop(); - - _isStopping = false; } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } - @override - Future addQueueItem(MediaItem mediaItem) async { - try { - await _queueAudioSource.add(await _mediaItemToAudioSource(mediaItem)); - queue.add(_queueFromSource()); - } catch (e) { - _audioServiceBackgroundTaskLogger.severe(e); - return Future.error(e); - } + int getPlayPositionInSeconds() { + return _player.position.inSeconds; } @override - Future updateQueue(List newQueue) async { + Future skipToPrevious() async { + bool doSkip = true; + try { - // Convert the MediaItems to AudioSources - List audioSources = []; - for (final mediaItem in newQueue) { - audioSources.add(await _mediaItemToAudioSource(mediaItem)); + if (_queueCallbackPreviousTrack != null) { + doSkip = await _queueCallbackPreviousTrack!(); + } else { + doSkip = _player.position.inSeconds < 5; } - // Create a new ConcatenatingAudioSource with the new queue. - _queueAudioSource = ConcatenatingAudioSource( - children: audioSources, - ); - - try { - await _player.setAudioSource( - _queueAudioSource, - initialIndex: nextInitialIndex, - ); - } on PlayerException catch (e) { - _audioServiceBackgroundTaskLogger - .severe("Player error code ${e.code}: ${e.message}"); - } on PlayerInterruptedException catch (e) { - _audioServiceBackgroundTaskLogger - .warning("Player interrupted: ${e.message}"); - } catch (e) { - _audioServiceBackgroundTaskLogger - .severe("Player error ${e.toString()}"); - } - queue.add(_queueFromSource()); - - // Sets the media item for the new queue. This will be whatever is - // currently playing from the new queue (for example, the first song in - // an album). If the player is shuffling, set the index to the player's - // current index. Otherwise, set it to nextInitialIndex. nextInitialIndex - // is much more stable than the current index as we know the value is set - // when running this function. - if (_player.shuffleModeEnabled) { - if (_player.currentIndex == null) { - _audioServiceBackgroundTaskLogger.severe( - "_player.currentIndex is null during onUpdateQueue, not setting new media item"); - } else { - mediaItem.add(_getQueueItem(_player.currentIndex!)); - } + if (!_player.hasPrevious) { + await _player.seek(Duration.zero); } else { - if (nextInitialIndex == null) { - _audioServiceBackgroundTaskLogger.severe( - "nextInitialIndex is null during onUpdateQueue, not setting new media item"); + if (doSkip) { + if (_player.loopMode == LoopMode.one) { + // if the user manually skips to the previous track, they probably want to actually skip to the previous track + await skipByOffset( + -1); //!!! don't use _player.previousIndex here, because that adjusts based on loop mode + } else { + await _player.seekToPrevious(); + } } else { - mediaItem.add(_getQueueItem(nextInitialIndex!)); + await _player.seek(Duration.zero); } } - - shuffleNextQueue = false; - nextInitialIndex = null; } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); @@ -269,23 +193,34 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } @override - Future skipToPrevious() async { + Future skipToNext() async { try { - if (!_player.hasPrevious || _player.position.inSeconds >= 5) { - await _player.seek(Duration.zero, index: _player.currentIndex); + 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 } else { - await _player.seek(Duration.zero, index: _player.previousIndex); + await _player.seekToNext(); } + _audioServiceBackgroundTaskLogger + .finer("_player.nextIndex: ${_player.nextIndex}"); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } - @override - Future skipToNext() async { + Future skipByOffset(int offset) async { + _audioServiceBackgroundTaskLogger.fine("skipping by offset: $offset"); + try { - await _player.seekToNext(); + await _player.seek(Duration.zero, + index: _player.shuffleModeEnabled + ? _queueAudioSource.shuffleIndices[_queueAudioSource + .shuffleIndices + .indexOf((_player.currentIndex ?? 0)) + + offset] + : (_player.currentIndex ?? 0) + offset); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); @@ -293,8 +228,12 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } Future skipToIndex(int index) async { + _audioServiceBackgroundTaskLogger.fine("skipping to index: $index"); + try { - await _player.seek(Duration.zero, index: index); + await _player.seek(Duration.zero, index: _player.shuffleModeEnabled + ? _queueAudioSource.shuffleIndices[index] + : index); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); @@ -311,21 +250,28 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } } + Future shuffle() async { + try { + await _player.shuffle(); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + @override Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { try { switch (shuffleMode) { case AudioServiceShuffleMode.all: await _player.setShuffleModeEnabled(true); - shuffleNextQueue = true; break; case AudioServiceShuffleMode.none: await _player.setShuffleModeEnabled(false); - shuffleNextQueue = false; break; default: return Future.error( - "Unsupported AudioServiceRepeatMode! Recieved ${shuffleMode.toString()}, requires all or none."); + "Unsupported AudioServiceRepeatMode! Received ${shuffleMode.toString()}, requires all or none."); } } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); @@ -348,7 +294,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { break; default: return Future.error( - "Unsupported AudioServiceRepeatMode! Recieved ${repeatMode.toString()}, requires all, none, or one."); + "Unsupported AudioServiceRepeatMode! Received ${repeatMode.toString()}, requires all, none, or one."); } } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); @@ -356,77 +302,10 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { } } - @override - Future removeQueueItemAt(int index) async { - try { - await _queueAudioSource.removeAt(index); - queue.add(_queueFromSource()); - } catch (e) { - _audioServiceBackgroundTaskLogger.severe(e); - return Future.error(e); - } - } - - /// Generates PlaybackProgressInfo from current player info. Returns null if - /// _queue is empty. If an item is not supplied, the current queue index will - /// be used. - PlaybackProgressInfo? generatePlaybackProgressInfo({ - MediaItem? item, - required bool includeNowPlayingQueue, - }) { - if (_queueAudioSource.length == 0 && item == null) { - // This function relies on _queue having items, so we return null if it's - // empty to avoid more errors. - return null; - } - - try { - return PlaybackProgressInfo( - itemId: item?.extras?["itemJson"]["Id"] ?? - _getQueueItem(_player.currentIndex ?? 0).extras!["itemJson"]["Id"], - isPaused: !_player.playing, - isMuted: _player.volume == 0, - positionTicks: _player.position.inMicroseconds * 10, - repeatMode: _jellyfinRepeatMode(_player.loopMode), - playMethod: item?.extras!["shouldTranscode"] ?? - _getQueueItem(_player.currentIndex ?? 0) - .extras!["shouldTranscode"] - ? "Transcode" - : "DirectPlay", - // We don't send the queue since it seems useless and it can cause - // issues with large queues. - // https://github.com/jmshrv/finamp/issues/387 - - // nowPlayingQueue: includeNowPlayingQueue - // ? _queueFromSource() - // .map( - // (e) => QueueItem( - // id: e.extras!["itemJson"]["Id"], playlistItemId: e.id), - // ) - // .toList() - // : null, - ); - } catch (e) { - _audioServiceBackgroundTaskLogger.severe(e); - rethrow; - } - } - void setNextInitialIndex(int index) { nextInitialIndex = index; } - Future reorderQueue(int oldIndex, int newIndex) async { - // When we're moving an item forwards, we need to reduce newIndex by 1 - // to account for the current item being removed before re-insertion. - if (oldIndex < newIndex) { - newIndex -= 1; - } - await _queueAudioSource.move(oldIndex, newIndex); - queue.add(_queueFromSource()); - _audioServiceBackgroundTaskLogger.log(Level.INFO, "Published queue"); - } - /// Sets the sleep timer with the given [duration]. Timer setSleepTimer(Duration duration) { _sleepTimerIsSet = true; @@ -486,133 +365,11 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { ); } - Future _updatePlaybackProgress() async { - try { - JellyfinApiHelper jellyfinApiHelper = GetIt.instance(); - - final playbackInfo = - generatePlaybackProgressInfo(includeNowPlayingQueue: false); - if (playbackInfo != null) { - await jellyfinApiHelper.updatePlaybackProgress(playbackInfo); - } - } catch (e) { - _audioServiceBackgroundTaskLogger.severe(e); - return Future.error(e); - } - } - - MediaItem _getQueueItem(int index) { - return _queueAudioSource.sequence[index].tag as MediaItem; - } - - List _queueFromSource() { - return _queueAudioSource.sequence.map((e) => e.tag as MediaItem).toList(); - } - - /// Syncs the list of MediaItems (_queue) with the internal queue of the player. - /// Called by onAddQueueItem and onUpdateQueue. - Future _mediaItemToAudioSource(MediaItem mediaItem) async { - if (mediaItem.extras!["downloadedSongJson"] == null) { - // If DownloadedSong wasn't passed, we assume that the item is not - // downloaded. - - // If offline, we throw an error so that we don't accidentally stream from - // the internet. See the big comment in _songUri() to see why this was - // passed in extras. - if (mediaItem.extras!["isOffline"]) { - return Future.error( - "Offline mode enabled but downloaded song not found."); - } else { - if (mediaItem.extras!["shouldTranscode"] == true) { - return HlsAudioSource(await _songUri(mediaItem), tag: mediaItem); - } else { - return AudioSource.uri(await _songUri(mediaItem), tag: mediaItem); - } - } - } else { - // We have to deserialise this because Dart is stupid and can't handle - // sending classes through isolates. - final downloadedSong = - DownloadedSong.fromJson(mediaItem.extras!["downloadedSongJson"]); - - // Path verification and stuff is done in AudioServiceHelper, so this path - // should be valid. - final downloadUri = Uri.file(downloadedSong.file.path); - return AudioSource.uri(downloadUri, tag: mediaItem); - } - } - - Future _songUri(MediaItem mediaItem) async { - // We need the platform to be Android or iOS to get device info - assert(Platform.isAndroid || Platform.isIOS, - "_songUri() only supports Android and iOS"); - - // When creating the MediaItem (usually in AudioServiceHelper), we specify - // whether or not to transcode. We used to pull from FinampSettings here, - // but since audio_service runs in an isolate (or at least, it does until - // 0.18), the value would be wrong if changed while a song was playing since - // Hive is bad at multi-isolate stuff. - - final androidId = - Platform.isAndroid ? await const AndroidId().getId() : null; - final iosDeviceInfo = - Platform.isIOS ? await DeviceInfoPlugin().iosInfo : null; - - final parsedBaseUrl = Uri.parse(_finampUserHelper.currentUser!.baseUrl); - - List builtPath = List.from(parsedBaseUrl.pathSegments); - - Map queryParameters = - Map.from(parsedBaseUrl.queryParameters); - - // We include the user token as a query parameter because just_audio used to - // have issues with headers in HLS, and this solution still works fine - queryParameters["ApiKey"] = _finampUserHelper.currentUser!.accessToken; - - if (mediaItem.extras!["shouldTranscode"]) { - builtPath.addAll([ - "Audio", - mediaItem.extras!["itemJson"]["Id"], - "main.m3u8", - ]); - - queryParameters.addAll({ - "audioCodec": "aac", - // Ideally we'd use 48kHz when the source is, realistically it doesn't - // matter too much - "audioSampleRate": "44100", - "maxAudioBitDepth": "16", - "audioBitRate": - FinampSettingsHelper.finampSettings.transcodeBitrate.toString(), - }); - } else { - builtPath.addAll([ - "Items", - mediaItem.extras!["itemJson"]["Id"], - "File", - ]); - } - - return Uri( - host: parsedBaseUrl.host, - port: parsedBaseUrl.port, - scheme: parsedBaseUrl.scheme, - userInfo: parsedBaseUrl.userInfo, - pathSegments: builtPath, - queryParameters: queryParameters, - ); - } -} - -String _jellyfinRepeatMode(LoopMode loopMode) { - switch (loopMode) { - case LoopMode.all: - return "RepeatAll"; - case LoopMode.one: - return "RepeatOne"; - case LoopMode.off: - return "RepeatNone"; - } + List? get effectiveSequence => + _player.sequenceState?.effectiveSequence; + double get volume => _player.volume; + bool get paused => !_player.playing; + Duration get playbackPosition => _player.position; } AudioServiceRepeatMode _audioServiceRepeatMode(LoopMode loopMode) { diff --git a/lib/services/playback_history_service.dart b/lib/services/playback_history_service.dart new file mode 100644 index 000000000..8a67cb602 --- /dev/null +++ b/lib/services/playback_history_service.dart @@ -0,0 +1,485 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'package:audio_service/audio_service.dart'; +import 'package:finamp/services/music_player_background_task.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:uuid/uuid.dart'; + +import 'finamp_user_helper.dart'; +import 'jellyfin_api_helper.dart'; +import 'finamp_settings_helper.dart'; +import '../models/finamp_models.dart'; +import '../models/jellyfin_models.dart' as jellyfin_models; + +/// A track queueing service for Finamp. +class PlaybackHistoryService { + final _jellyfinApiHelper = GetIt.instance(); + final _audioService = GetIt.instance(); + final _queueService = GetIt.instance(); + final _playbackHistoryServiceLogger = Logger("PlaybackHistoryService"); + + // internal state + + final List _history = + []; // contains **all** items that have been played, including "next up" + FinampHistoryItem? _currentTrack; // the currently playing track + + PlaybackState? _previousPlaybackState; + final bool _reportQueueToServer = true; + DateTime _lastPositionUpdate = DateTime.now(); + + final _historyStream = BehaviorSubject>.seeded( + List.empty(growable: true), + ); + + PlaybackHistoryService() { + _queueService.getCurrentTrackStream().listen((currentTrack) { + updateCurrentTrack(currentTrack); + + if (currentTrack == null) { + _reportPlaybackStopped(); + } + }); + + _audioService.playbackState.listen((event) { + final prevState = _previousPlaybackState; + final prevItem = _currentTrack?.item; + final currentState = event; + final currentIndex = currentState.queueIndex; + + 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)) { + _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 + onTrackChanged(currentItem, currentState, prevItem, prevState, + currentIndex > (prevState?.queueIndex ?? 0)); + } + // handle events that don't change the current track (e.g. loop, pause, seek, etc.) + + // handle play/pause events + else if (currentState.playing != prevState?.playing) { + _playbackHistoryServiceLogger + .fine("Reporting play/pause event for ${currentItem.item.title}"); + onPlaybackStateChanged(currentItem, currentState); + } + // handle seeking (changes updateTime (= last abnormal position change)) + else if (currentState.playing && + currentState.updateTime != prevState?.updateTime && + currentState.bufferedPosition == prevState?.bufferedPosition) { + // detect rewinding & looping a single track + if ( + // same track + prevItem?.id == currentItem.id && + // current position is close to the beginning of the track + currentState.position.inMilliseconds <= 1000 * 10) { + if ((prevState?.position.inMilliseconds ?? 0) >= + ((prevItem?.item.duration?.inMilliseconds ?? 0) - 1000 * 10)) { + // looping a single track + // last position was close to the end of the track + updateCurrentTrack(currentItem, forceNewTrack: true); // add to playback history + //TODO handle reporting track changes based on history changes, as that is more reliable + onTrackChanged( + currentItem, currentState, prevItem, prevState, true); + return; // don't report seek event + } else { + // rewinding + updateCurrentTrack(currentItem, forceNewTrack: true); // add to playback history + // don't return, report seek event + } + } + + // rate limit updates (only send update after no changes for 3 seconds) and if the track is still the same + Future.delayed(const Duration(seconds: 3, milliseconds: 500), () { + if (_lastPositionUpdate + .add(const Duration(seconds: 3)) + .isBefore(DateTime.now()) && + currentItem.id == _queueService.getCurrentTrack()?.id) { + _playbackHistoryServiceLogger + .fine("Reporting seek event for ${currentItem.item.title}"); + onPlaybackStateChanged(currentItem, currentState); + } + _lastPositionUpdate = DateTime.now(); + }); + } + // maybe handle toggling shuffle when sending the queue? would result in duplicate entries in the activity log, so maybe it's not desirable + // same for updating the queue / next up + } + + _previousPlaybackState = event; + }); + + //TODO Tell Jellyfin we're not / no longer playing audio on startup - doesn't currently work because an item ID is required, and we don't have one (yet) + // if (!FinampSettingsHelper.finampSettings.isOffline) { + // final playbackInfo = generatePlaybackProgressInfoFromState(const MediaItem(id: "", title: ""), _audioService.playbackState.valueOrNull ?? PlaybackState()); + // if (playbackInfo != null) { + // _playbackHistoryServiceLogger.info("Stopping playback progress after startup"); + // _jellyfinApiHelper.stopPlaybackProgress(playbackInfo); + // } + // } + } + + get history => _history; + BehaviorSubject> get historyStream => _historyStream; + + /// method that converts history into a list grouped by date + List>> + getHistoryGroupedDynamically() { + byDateGroupingConstructor(FinampHistoryItem element) { + final now = DateTime.now(); + if (now.year == element.startTime.year && + now.month == element.startTime.month && + now.day == element.startTime.day && + now.hour == element.startTime.hour) { + // group by minute + return DateTime( + element.startTime.year, + element.startTime.month, + element.startTime.day, + element.startTime.hour, + element.startTime.minute, + ); + } else if (now.year == element.startTime.year && + now.month == element.startTime.month && + now.day == element.startTime.day) { + // group by hour + return DateTime( + element.startTime.year, + element.startTime.month, + element.startTime.day, + element.startTime.hour, + ); + } + // group by date + return DateTime( + element.startTime.year, + element.startTime.month, + element.startTime.day, + ); + } + + return getHistoryGrouped(byDateGroupingConstructor); + } + + /// method that converts history into a list grouped by date + List>> getHistoryGroupedByDate() { + byDateGroupingConstructor(FinampHistoryItem element) { + return DateTime( + element.startTime.year, + element.startTime.month, + element.startTime.day, + ); + } + + return getHistoryGrouped(byDateGroupingConstructor); + } + + /// method that converts history into a list grouped by hour + List>> getHistoryGroupedByHour() { + byHourGroupingConstructor(FinampHistoryItem element) { + return DateTime( + element.startTime.year, + element.startTime.month, + element.startTime.day, + element.startTime.hour, + ); + } + + return getHistoryGrouped(byHourGroupingConstructor); + } + + /// method that converts history into a list grouped by a custom date constructor controlling the granularity of the grouping + List>> getHistoryGrouped( + DateTime Function(FinampHistoryItem) dateTimeConstructor) { + final groupedHistory = >>[]; + + final groupedHistoryMap = >{}; + + for (var element in _history) { + final date = dateTimeConstructor(element); + + if (groupedHistoryMap.containsKey(date)) { + groupedHistoryMap[date]!.add(element); + } else { + groupedHistoryMap[date] = [element]; + } + } + + groupedHistoryMap.forEach((key, value) { + groupedHistory.add(MapEntry(key, value)); + }); + + // sort by minute (most recent first) + groupedHistory.sort((a, b) => b.key.compareTo(a.key)); + + return groupedHistory; + } + + void updateCurrentTrack(FinampQueueItem? currentTrack, { + bool forceNewTrack = false, + }) { + if (currentTrack == null || + !forceNewTrack && ( + currentTrack == _currentTrack?.item || + currentTrack.item.id == "" || + currentTrack.id == _currentTrack?.item.id + ) + ) { + // current track hasn't changed + return; + } + + int previousTrackTotalPlayTimeInMilliseconds = 0; + // if there is a **previous** track + if (_currentTrack != null) { + // update end time of previous track + _currentTrack!.endTime = DateTime.now(); + previousTrackTotalPlayTimeInMilliseconds = _currentTrack!.endTime! + .difference(_currentTrack!.startTime) + .inMilliseconds; + } + + if (previousTrackTotalPlayTimeInMilliseconds < 1000) { + // replace history item with current track + if (_history.isNotEmpty) { + _history.removeLast(); + } + } + + // if there is a **current** track + _currentTrack = FinampHistoryItem( + item: currentTrack, + startTime: DateTime.now(), + ); + _history.add( + _currentTrack!); // current track is always the last item in the history + + _historyStream.add(_history); + } + + /// Report track changes to the Jellyfin Server if the user is not offline. + Future onTrackChanged( + FinampQueueItem currentItem, + PlaybackState currentState, + FinampQueueItem? previousItem, + PlaybackState? previousState, + bool skippingForward, + ) async { + if (FinampSettingsHelper.finampSettings.isOffline) { + return; + } + + jellyfin_models.PlaybackProgressInfo? previousTrackPlaybackData; + if (previousItem != null && + previousState != null && + // don't submit stop events for idle tracks (at position 0 and not playing) + (previousState.playing || + previousState.updatePosition != Duration.zero)) { + previousTrackPlaybackData = generatePlaybackProgressInfoFromState( + previousItem.item, + previousState, + ); + } + + // prevent reporting the same track twice if playback hasn't started yet + if (!currentState.playing) { + return; + } + + final newTrackplaybackData = generatePlaybackProgressInfoFromState( + currentItem.item, + currentState, + ); + + //!!! always submit a "start" **AFTER** a "stop" to that Jellyfin knows there's still something playing + if (previousTrackPlaybackData != null) { + _playbackHistoryServiceLogger + .info("Stopping playback progress for ${previousItem?.item.title}"); + await _jellyfinApiHelper.stopPlaybackProgress(previousTrackPlaybackData); + //TODO also mark the track as played in the user data: https://api.jellyfin.org/openapi/api.html#tag/Playstate/operation/MarkPlayedItem + } + if (newTrackplaybackData != null) { + _playbackHistoryServiceLogger + .info("Starting playback progress for ${currentItem.item.title}"); + await _jellyfinApiHelper.reportPlaybackStart(newTrackplaybackData); + } + } + + /// Report track changes to the Jellyfin Server if the user is not offline. + Future onPlaybackStateChanged( + FinampQueueItem currentItem, + PlaybackState currentState, + ) async { + if (FinampSettingsHelper.finampSettings.isOffline) { + return; + } + + final playbackData = generatePlaybackProgressInfoFromState( + currentItem.item, + currentState, + ); + + if (playbackData != null) { + if (![AudioProcessingState.completed, AudioProcessingState.idle] + .contains(currentState.processingState)) { + _playbackHistoryServiceLogger + .info("Starting playback progress for ${currentItem.item.title}"); + await _jellyfinApiHelper.reportPlaybackStart(playbackData); + } else { + _playbackHistoryServiceLogger + .info("Stopping playback progress for ${currentItem.item.title}"); + await _jellyfinApiHelper.stopPlaybackProgress(playbackData); + } + } + } + + /// Generates PlaybackProgressInfo for the supplied item and playback state. + jellyfin_models.PlaybackProgressInfo? generatePlaybackProgressInfoFromState( + MediaItem item, + PlaybackState state, + ) { + final duration = item.duration; + return generatePlaybackProgressInfo( + item, + isPaused: !state.playing, + // always consider as unmuted + isMuted: false, + // ensure the (extrapolated) position doesn't exceed the duration + playerPosition: duration != null && state.position > duration + ? duration + : state.position, + repeatMode: _jellyfinRepeatModeFromRepeatMode(state.repeatMode), + includeNowPlayingQueue: _reportQueueToServer, + ); + } + + Future _reportPlaybackStopped() async { + final playbackInfo = generateGenericPlaybackProgressInfo(); + if (playbackInfo != null) { + await _jellyfinApiHelper.stopPlaybackProgress(playbackInfo); + } + } + + /// Generates PlaybackProgressInfo for the supplied item and player info. + jellyfin_models.PlaybackProgressInfo? generatePlaybackProgressInfo( + MediaItem item, { + required bool isPaused, + required bool isMuted, + required Duration playerPosition, + required String repeatMode, + required bool includeNowPlayingQueue, + }) { + try { + List? nowPlayingQueue; + if (includeNowPlayingQueue) { + nowPlayingQueue = _queueService + .getNextXTracksInQueue(30) + .map((e) => jellyfin_models.QueueItem( + id: e.item.id, + playlistItemId: e.source.id, + )) + .toList(); + } + + return jellyfin_models.PlaybackProgressInfo( + itemId: item.extras?["itemJson"]["Id"] ?? "", + isPaused: isPaused, + isMuted: isMuted, + positionTicks: playerPosition.inMicroseconds * 10, + repeatMode: repeatMode, + playMethod: item.extras?["shouldTranscode"] ?? false + ? "Transcode" + : "DirectPlay", + nowPlayingQueue: nowPlayingQueue, + ); + } catch (e) { + _playbackHistoryServiceLogger.severe(e); + return null; + // rethrow; + } + } + + /// Generates PlaybackProgressInfo from current player info. + jellyfin_models.PlaybackProgressInfo? generateGenericPlaybackProgressInfo({ + bool includeNowPlayingQueue = false, + }) { + if (_history.isEmpty || _currentTrack == null) { + // This function relies on _history having items + return null; + } + + try { + final itemId = _currentTrack!.item.item.extras?["itemJson"]["Id"]; + + if (itemId == null) { + _playbackHistoryServiceLogger.warning( + "Current track item ID is null, cannot generate playback progress info.", + ); + return null; + } + + return jellyfin_models.PlaybackProgressInfo( + itemId: _currentTrack!.item.item.extras?["itemJson"]["Id"], + isPaused: _audioService.paused, + isMuted: _audioService.volume == 0.0, + volumeLevel: _audioService.volume.round(), + positionTicks: _audioService.playbackPosition.inMicroseconds * 10, + repeatMode: _toJellyfinRepeatMode(_queueService.loopMode), + playbackStartTimeTicks: + _currentTrack!.startTime.millisecondsSinceEpoch * 1000 * 10, + playMethod: _currentTrack!.item.item.extras!["shouldTranscode"] + ? "Transcode" + : "DirectPlay", + // We don't send the queue since it seems useless and it can cause + // issues with large queues. + // https://github.com/jmshrv/finamp/issues/387 + nowPlayingQueue: includeNowPlayingQueue + ? _queueService + .getQueue() + .nextUp + .followedBy(_queueService.getQueue().queue) + .map( + (e) => jellyfin_models.QueueItem( + id: e.item.extras!["itemJson"]["Id"], + playlistItemId: e.item.id), + ) + .toList() + : null, + ); + } catch (e) { + _playbackHistoryServiceLogger.severe(e); + rethrow; + } + } + + String _jellyfinRepeatModeFromRepeatMode(AudioServiceRepeatMode repeatMode) { + switch (repeatMode) { + case AudioServiceRepeatMode.none: + return "RepeatNone"; + case AudioServiceRepeatMode.one: + return "RepeatOne"; + case AudioServiceRepeatMode.all: + case AudioServiceRepeatMode.group: + return "RepeatAll"; + } + } + + String _toJellyfinRepeatMode(FinampLoopMode loopMode) { + switch (loopMode) { + case FinampLoopMode.all: + return "RepeatAll"; + case FinampLoopMode.one: + return "RepeatOne"; + case FinampLoopMode.none: + return "RepeatNone"; + } + } +} diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart new file mode 100644 index 000000000..65d4fc9ce --- /dev/null +++ b/lib/services/queue_service.dart @@ -0,0 +1,880 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +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:rxdart/rxdart.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; +import 'finamp_user_helper.dart'; +import 'jellyfin_api_helper.dart'; +import 'finamp_settings_helper.dart'; +import 'downloads_helper.dart'; +import 'music_player_background_task.dart'; + +/// A track queueing service for Finamp. +class QueueService { + final _jellyfinApiHelper = GetIt.instance(); + final _downloadsHelper = GetIt.instance(); + final _audioHandler = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + final _queueServiceLogger = Logger("QueueService"); + // internal state + + final List _queuePreviousTracks = + []; // contains **all** items that have been played, including "next up" + FinampQueueItem? _currentTrack; // the currently playing track + final List _queueNextUp = + []; // a temporary queue that gets appended to if the user taps "next up" + final List _queue = []; // contains all regular queue items + FinampQueueOrder _order = FinampQueueOrder( + items: [], + originalSource: QueueItemSource( + id: "", + name: const QueueItemSourceName( + type: QueueItemSourceNameType.preTranslated), + type: QueueItemSourceType.unknown), + linearOrder: [], + shuffledOrder: []); // contains all items that were at some point added to the regular queue, as well as their order when shuffle is enabled and disabled. This is used to loop the original queue once the end has been reached and "loop all" is enabled, **excluding** "next up" items and keeping the playback order. + + FinampPlaybackOrder _playbackOrder = FinampPlaybackOrder.linear; + FinampLoopMode _loopMode = FinampLoopMode.none; + + final _currentTrackStream = BehaviorSubject.seeded(null); + final _queueStream = BehaviorSubject.seeded(null); + + final _playbackOrderStream = + BehaviorSubject.seeded(FinampPlaybackOrder.linear); + final _loopModeStream = + BehaviorSubject.seeded(FinampLoopMode.none); + + // external queue state + + // the audio source used by the player. The first X items of all internal queues are merged together into this source, so that all player features, like gapless playback, are supported + ConcatenatingAudioSource _queueAudioSource = ConcatenatingAudioSource( + children: [], + ); + late ShuffleOrder _shuffleOrder; + int _queueAudioSourceIndex = 0; + + QueueService() { + // _queueServiceLogger.level = Level.OFF; + + final finampSettings = FinampSettingsHelper.finampSettings; + + loopMode = finampSettings.loopMode; + _queueServiceLogger.info("Restored loop mode to $loopMode from settings"); + + _shuffleOrder = NextUpShuffleOrder(queueService: this); + _queueAudioSource = ConcatenatingAudioSource( + children: [], + shuffleOrder: _shuffleOrder, + ); + + _audioHandler.playbackState.listen((event) async { + // int indexDifference = (event.currentIndex ?? 0) - _queueAudioSourceIndex; + + final previousIndex = _queueAudioSourceIndex; + _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)"); + _queueFromConcatenatingAudioSource(); + } + }); + + // register callbacks + // _audioHandler.setQueueCallbacks( + // nextTrackCallback: _applyNextTrack, + // previousTrackCallback: _applyPreviousTrack, + // skipToIndexCallback: _applySkipToTrackByOffset, + // ); + } + + void _queueFromConcatenatingAudioSource() { + List allTracks = _audioHandler.effectiveSequence + ?.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; + final nextUpPreviousLength = _queueNextUp.length; + final queuePreviousLength = _queue.length; + + _queuePreviousTracks.clear(); + _queueNextUp.clear(); + _queue.clear(); + + bool canHaveNextUp = true; + + // split the queue by old type + for (int i = 0; i < allTracks.length; i++) { + if (i < adjustedQueueIndex) { + _queuePreviousTracks.add(allTracks[i]); + if ([ + QueueItemSourceType.nextUp, + QueueItemSourceType.nextUpAlbum, + QueueItemSourceType.nextUpPlaylist, + QueueItemSourceType.nextUpArtist + ].contains(_queuePreviousTracks.last.source.type)) { + _queuePreviousTracks.last.source = QueueItemSource( + type: QueueItemSourceType.formerNextUp, + name: const QueueItemSourceName( + type: QueueItemSourceNameType.tracksFormerNextUp), + id: "former-next-up"); + } + _queuePreviousTracks.last.type = QueueItemQueueType.previousTracks; + } else if (i == adjustedQueueIndex) { + _currentTrack = allTracks[i]; + _currentTrack!.type = QueueItemQueueType.currentTrack; + } else { + if (allTracks[i].type == QueueItemQueueType.currentTrack && + [ + QueueItemSourceType.nextUp, + QueueItemSourceType.nextUpAlbum, + QueueItemSourceType.nextUpPlaylist, + QueueItemSourceType.nextUpArtist + ].contains(allTracks[i].source.type)) { + _queue.add(allTracks[i]); + _queue.last.type = QueueItemQueueType.queue; + _queue.last.source = QueueItemSource( + type: QueueItemSourceType.formerNextUp, + name: const QueueItemSourceName( + type: QueueItemSourceNameType.tracksFormerNextUp), + id: "former-next-up"); + canHaveNextUp = false; + } else if (allTracks[i].type == QueueItemQueueType.nextUp) { + if (canHaveNextUp) { + _queueNextUp.add(allTracks[i]); + _queueNextUp.last.type = QueueItemQueueType.nextUp; + } else { + _queue.add(allTracks[i]); + _queue.last.type = QueueItemQueueType.queue; + _queue.last.source = QueueItemSource( + type: QueueItemSourceType.formerNextUp, + name: const QueueItemSourceName( + type: QueueItemSourceNameType.tracksFormerNextUp), + id: "former-next-up"); + } + } else { + _queue.add(allTracks[i]); + _queue.last.type = QueueItemQueueType.queue; + canHaveNextUp = false; + } + } + } + + if (allTracks.isEmpty) { + _queueServiceLogger.fine("Queue is empty"); + _currentTrack = null; + return; + } + + final newQueueInfo = getQueue(); + _queueStream.add(newQueueInfo); + _currentTrackStream.add(_currentTrack); + _audioHandler.mediaItem.add(_currentTrack?.item); + _audioHandler.queue.add(_queuePreviousTracks + .followedBy([_currentTrack!]) + .followedBy(_queue) + .map((e) => e.item) + .toList()); + + // only log queue if there's a change + if (previousTrack?.id != _currentTrack?.id || + previousTracksPreviousLength != _queuePreviousTracks.length || + nextUpPreviousLength != _queueNextUp.length || + queuePreviousLength != _queue.length) { + _logQueues(message: "(current)"); + } + } + + Future startPlayback({ + required List items, + required QueueItemSource source, + FinampPlaybackOrder? order, + int? startingIndex, + }) async { + // _initialQueue = list; // save original PlaybackList for looping/restarting and meta info + + if (startingIndex == null) { + if (order == FinampPlaybackOrder.shuffled) { + startingIndex = Random().nextInt(items.length); + } else { + startingIndex = 0; + } + } + + await _replaceWholeQueue( + itemList: items, + source: source, + order: order, + initialIndex: startingIndex); + _queueServiceLogger + .info("Started playing '${source.name}' (${source.type})"); + } + + /// Replaces the queue with the given list of items. If startAtIndex is specified, Any items below it + /// will be ignored. This is used for when the user taps in the middle of an album to start from that point. + Future _replaceWholeQueue({ + required List itemList, + required QueueItemSource source, + required int initialIndex, + FinampPlaybackOrder? order, + }) async { + try { + if (initialIndex > itemList.length) { + return Future.error( + "initialIndex is bigger than the itemList! ($initialIndex > ${itemList.length})"); + } + + _queue.clear(); // empty queue + _queuePreviousTracks.clear(); + _queueNextUp.clear(); + _currentTrack = null; + + List newItems = []; + List newLinearOrder = []; + List newShuffledOrder; + for (int i = 0; i < itemList.length; i++) { + jellyfin_models.BaseItemDto item = itemList[i]; + try { + MediaItem mediaItem = await _generateMediaItem(item); + newItems.add(FinampQueueItem( + item: mediaItem, + source: source, + type: i == 0 + ? QueueItemQueueType.currentTrack + : QueueItemQueueType.queue, + )); + newLinearOrder.add(i); + } catch (e) { + _queueServiceLogger.severe(e); + } + } + + await _audioHandler.stop(); + _queueAudioSource.clear(); + // await _audioHandler.initializeAudioSource(_queueAudioSource); + + List audioSources = []; + + for (final queueItem in newItems) { + audioSources.add(await _queueItemToAudioSource(queueItem)); + } + + await _queueAudioSource.addAll(audioSources); + + // set first item in queue + _queueAudioSourceIndex = initialIndex; + if (_playbackOrder == FinampPlaybackOrder.shuffled) { + _queueAudioSourceIndex = _queueAudioSource.shuffleIndices[initialIndex]; + } + _audioHandler.setNextInitialIndex(_queueAudioSourceIndex); + await _audioHandler.initializeAudioSource(_queueAudioSource); + + newShuffledOrder = List.from(_queueAudioSource.shuffleIndices); + + _order = FinampQueueOrder( + items: newItems, + originalSource: source, + linearOrder: newLinearOrder, + shuffledOrder: newShuffledOrder, + ); + + _queueServiceLogger.fine("Order items length: ${_order.items.length}"); + + // set playback order to trigger shuffle if necessary (fixes indices being wrong when starting with shuffle enabled) + + if (order != null) { + playbackOrder = order; + } + + // _queueStream.add(getQueue()); + _queueFromConcatenatingAudioSource(); + + await _audioHandler.play(); + + _audioHandler.nextInitialIndex = null; + } catch (e) { + _queueServiceLogger.severe(e); + return Future.error(e); + } + } + + Future stopPlayback() async { + queueServiceLogger.info("Stopping playback"); + + await _audioHandler.stop(); + + _queueAudioSource.clear(); + + _queueFromConcatenatingAudioSource(); + + return; + } + + Future addToQueue({ + required List items, + QueueItemSource? source, + }) async { + try { + List queueItems = []; + for (final item in items) { + queueItems.add(FinampQueueItem( + item: await _generateMediaItem(item), + source: source ?? _order.originalSource, + type: QueueItemQueueType.queue, + )); + } + + List audioSources = []; + for (final item in queueItems) { + audioSources.add(await _queueItemToAudioSource(item)); + _queueServiceLogger.fine( + "Added '${item.item.title}' to queue from '${source?.name}' (${source?.type})"); + } + await _queueAudioSource.addAll(audioSources); + + _queueFromConcatenatingAudioSource(); // update internal queues + } catch (e) { + _queueServiceLogger.severe(e); + return Future.error(e); + } + } + + Future addNext({ + required List items, + QueueItemSource? source, + }) async { + try { + List queueItems = []; + for (final item in items) { + queueItems.add(FinampQueueItem( + item: await _generateMediaItem(item), + source: source ?? + QueueItemSource( + id: "next-up", + name: const QueueItemSourceName( + type: QueueItemSourceNameType.nextUp), + type: QueueItemSourceType.nextUp), + type: QueueItemQueueType.nextUp, + )); + } + + for (final queueItem in queueItems.reversed) { + await _queueAudioSource.insert(_queueAudioSourceIndex + 1, + await _queueItemToAudioSource(queueItem)); + _queueServiceLogger.fine( + "Appended '${queueItem.item.title}' to Next Up (index ${_queueAudioSourceIndex + 1})"); + } + + _queueFromConcatenatingAudioSource(); // update internal queues + } catch (e) { + _queueServiceLogger.severe(e); + return Future.error(e); + } + } + + Future addToNextUp({ + required List items, + QueueItemSource? source, + }) async { + try { + List queueItems = []; + for (final item in items) { + queueItems.add(FinampQueueItem( + item: await _generateMediaItem(item), + source: source ?? + QueueItemSource( + id: "next-up", + name: const QueueItemSourceName( + type: QueueItemSourceNameType.nextUp), + type: QueueItemSourceType.nextUp), + type: QueueItemQueueType.nextUp, + )); + } + + _queueFromConcatenatingAudioSource(); // update internal queues + int offset = _queueNextUp.length; + + for (final queueItem in queueItems) { + await _queueAudioSource.insert(_queueAudioSourceIndex + 1 + offset, + await _queueItemToAudioSource(queueItem)); + _queueServiceLogger.fine( + "Appended '${queueItem.item.title}' to Next Up (index ${_queueAudioSourceIndex + 1 + offset})"); + offset++; + } + + _queueFromConcatenatingAudioSource(); // update internal queues + } catch (e) { + _queueServiceLogger.severe(e); + return Future.error(e); + } + } + + Future skipByOffset(int offset) async { + await _audioHandler.skipByOffset(offset); + } + + Future removeAtOffset(int offset) async { + final index = _playbackOrder == FinampPlaybackOrder.shuffled + ? _queueAudioSource.shuffleIndices[ + _queueAudioSource.shuffleIndices.indexOf((_queueAudioSourceIndex)) + + offset] + : (_queueAudioSourceIndex) + offset; + + await _queueAudioSource.removeAt(index); + // await _audioHandler.removeQueueItemAt(index); + _queueFromConcatenatingAudioSource(); + } + + Future reorderByOffset(int oldOffset, int newOffset) async { + _queueServiceLogger.fine( + "Reordering queue item at offset $oldOffset to offset $newOffset"); + + //!!! the player will automatically change the shuffle indices of the ConcatenatingAudioSource if shuffle is enabled, so we need to use the regular track index here + final oldIndex = _queueAudioSourceIndex + oldOffset; + final newIndex = oldOffset < newOffset + ? _queueAudioSourceIndex + newOffset - 1 + : _queueAudioSourceIndex + newOffset; + + await _queueAudioSource.move(oldIndex, newIndex); + _queueFromConcatenatingAudioSource(); + } + + Future clearNextUp() async { + // remove all items from Next Up + if (_queueNextUp.isNotEmpty) { + await _queueAudioSource.removeRange(_queueAudioSourceIndex + 1, + _queueAudioSourceIndex + 1 + _queueNextUp.length); + _queueNextUp.clear(); + } + + _queueFromConcatenatingAudioSource(); // update internal queues + } + + FinampQueueInfo getQueue() { + return FinampQueueInfo( + previousTracks: _queuePreviousTracks, + currentTrack: _currentTrack, + queue: _queue, + nextUp: _queueNextUp, + source: _order.originalSource, + ); + } + + BehaviorSubject getQueueStream() { + return _queueStream; + } + + void refreshQueueStream() { + _queueStream.add(getQueue()); + } + + /// Returns the next [amount] QueueItems from Next Up and the regular queue. + /// The length of the returned list may be less than [amount] if there are not enough items in the queue + List getNextXTracksInQueue(int amount) { + List nextTracks = []; + if (_queueNextUp.isNotEmpty) { + nextTracks + .addAll(_queueNextUp.sublist(0, min(amount, _queueNextUp.length))); + amount -= _queueNextUp.length; + } + if (_queue.isNotEmpty && amount > 0) { + nextTracks.addAll(_queue.sublist(0, min(amount, _queue.length))); + } + return nextTracks; + } + + BehaviorSubject getPlaybackOrderStream() { + return _playbackOrderStream; + } + + BehaviorSubject getLoopModeStream() { + return _loopModeStream; + } + + BehaviorSubject getCurrentTrackStream() { + return _currentTrackStream; + } + + FinampQueueItem? getCurrentTrack() { + return _currentTrack; + } + + set loopMode(FinampLoopMode mode) { + _loopMode = mode; + + _loopModeStream.add(mode); + + if (mode == FinampLoopMode.one) { + _audioHandler.setRepeatMode(AudioServiceRepeatMode.one); + } else if (mode == FinampLoopMode.all) { + _audioHandler.setRepeatMode(AudioServiceRepeatMode.all); + } else { + _audioHandler.setRepeatMode(AudioServiceRepeatMode.none); + } + + FinampSettingsHelper.setLoopMode(loopMode); + _queueServiceLogger.fine( + "Loop mode set to ${FinampSettingsHelper.finampSettings.loopMode}"); + } + + FinampLoopMode get loopMode => _loopMode; + + set playbackOrder(FinampPlaybackOrder order) { + _playbackOrder = order; + _queueServiceLogger.fine("Playback order set to $order"); + + _playbackOrderStream.add(order); + + // update queue accordingly and generate new shuffled order if necessary + if (_playbackOrder == FinampPlaybackOrder.shuffled) { + _audioHandler.shuffle().then((_) => _audioHandler + .setShuffleMode(AudioServiceShuffleMode.all) + .then((_) => _queueFromConcatenatingAudioSource())); + } else { + _audioHandler + .setShuffleMode(AudioServiceShuffleMode.none) + .then((_) => _queueFromConcatenatingAudioSource()); + } + } + + FinampPlaybackOrder get playbackOrder => _playbackOrder; + + void togglePlaybackOrder() { + if (_playbackOrder == FinampPlaybackOrder.shuffled) { + playbackOrder = FinampPlaybackOrder.linear; + } else { + playbackOrder = FinampPlaybackOrder.shuffled; + } + } + + void toggleLoopMode() { + if (_loopMode == FinampLoopMode.all) { + loopMode = FinampLoopMode.one; + } else if (_loopMode == FinampLoopMode.one) { + loopMode = FinampLoopMode.none; + } else { + loopMode = FinampLoopMode.all; + } + } + + Logger get queueServiceLogger => _queueServiceLogger; + + void _logQueues({String message = ""}) { + // generate string for `_queue` + String queueString = ""; + for (FinampQueueItem queueItem in _queuePreviousTracks) { + queueString += "${queueItem.item.title}, "; + } + queueString += "[[${_currentTrack?.item.title}]], "; + queueString += "{"; + for (FinampQueueItem queueItem in _queueNextUp) { + queueString += "${queueItem.item.title}, "; + } + queueString += "} "; + for (FinampQueueItem queueItem in _queue) { + queueString += "${queueItem.item.title}, "; + } + + // generate string for `_queueAudioSource` + // String queueAudioSourceString = ""; + // queueAudioSourceString += "[${_queueAudioSource.sequence.first.toString()}], "; + // for (AudioSource queueItem in _queueAudioSource.sequence.sublist(1)) { + // queueAudioSourceString += "${queueItem.toString()}, "; + // } + + // log queues + _queueServiceLogger.finer( + "Queue $message [${_queuePreviousTracks.length}-1-${_queueNextUp.length}-${_queue.length}]: $queueString"); + // _queueServiceLogger.finer( + // "Audio Source Queue $message [${_queue.length}]: $queueAudioSourceString" + // ) + } + + Future _generateMediaItem(jellyfin_models.BaseItemDto item) async { + const uuid = Uuid(); + + final downloadedSong = _downloadsHelper.getDownloadedSong(item.id); + final isDownloaded = downloadedSong == null + ? false + : await _downloadsHelper.verifyDownloadedSong(downloadedSong); + + return MediaItem( + id: uuid.v4(), + album: item.album ?? "unknown", + artist: item.artists?.join(", ") ?? item.albumArtist, + artUri: _downloadsHelper.getDownloadedImage(item)?.file.uri ?? + _jellyfinApiHelper.getImageUrl(item: item), + 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: + (item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10), + ), + ); + } + + /// Syncs the list of MediaItems (_queue) with the internal queue of the player. + /// Called by onAddQueueItem and onUpdateQueue. + Future _queueItemToAudioSource(FinampQueueItem queueItem) async { + if (queueItem.item.extras!["downloadedSongJson"] == null) { + // If DownloadedSong wasn't passed, we assume that the item is not + // downloaded. + + // If offline, we throw an error so that we don't accidentally stream from + // the internet. See the big comment in _songUri() to see why this was + // passed in extras. + if (queueItem.item.extras!["isOffline"]) { + return Future.error( + "Offline mode enabled but downloaded song not found."); + } else { + if (queueItem.item.extras!["shouldTranscode"] == true) { + return HlsAudioSource(await _songUri(queueItem.item), tag: queueItem); + } else { + return AudioSource.uri(await _songUri(queueItem.item), + tag: queueItem); + } + } + } else { + // We have to deserialise this because Dart is stupid and can't handle + // sending classes through isolates. + final downloadedSong = + DownloadedSong.fromJson(queueItem.item.extras!["downloadedSongJson"]); + + // Path verification and stuff is done in AudioServiceHelper, so this path + // should be valid. + final downloadUri = Uri.file(downloadedSong.file.path); + return AudioSource.uri(downloadUri, tag: queueItem); + } + } + + Future _songUri(MediaItem mediaItem) async { + // We need the platform to be Android or iOS to get device info + assert(Platform.isAndroid || Platform.isIOS, + "_songUri() only supports Android and iOS"); + + // When creating the MediaItem (usually in AudioServiceHelper), we specify + // whether or not to transcode. We used to pull from FinampSettings here, + // but since audio_service runs in an isolate (or at least, it does until + // 0.18), the value would be wrong if changed while a song was playing since + // Hive is bad at multi-isolate stuff. + + final parsedBaseUrl = Uri.parse(_finampUserHelper.currentUser!.baseUrl); + + List builtPath = List.from(parsedBaseUrl.pathSegments); + + Map queryParameters = + Map.from(parsedBaseUrl.queryParameters); + + // We include the user token as a query parameter because just_audio used to + // have issues with headers in HLS, and this solution still works fine + queryParameters["ApiKey"] = _finampUserHelper.currentUser!.accessToken; + + if (mediaItem.extras!["shouldTranscode"]) { + builtPath.addAll([ + "Audio", + mediaItem.extras!["itemJson"]["Id"], + "main.m3u8", + ]); + + queryParameters.addAll({ + "audioCodec": "aac", + // Ideally we'd use 48kHz when the source is, realistically it doesn't + // matter too much + "audioSampleRate": "44100", + "maxAudioBitDepth": "16", + "audioBitRate": + FinampSettingsHelper.finampSettings.transcodeBitrate.toString(), + }); + } else { + builtPath.addAll([ + "Items", + mediaItem.extras!["itemJson"]["Id"], + "File", + ]); + } + + return Uri( + host: parsedBaseUrl.host, + port: parsedBaseUrl.port, + scheme: parsedBaseUrl.scheme, + userInfo: parsedBaseUrl.userInfo, + pathSegments: builtPath, + queryParameters: queryParameters, + ); + } +} + +class NextUpShuffleOrder extends ShuffleOrder { + final Random _random; + final QueueService? _queueService; + @override + List indices = []; + + NextUpShuffleOrder({Random? random, QueueService? queueService}) + : _random = random ?? Random(), + _queueService = queueService; + + @override + void shuffle({int? initialIndex}) { + assert(initialIndex == null || indices.contains(initialIndex)); + + if (initialIndex == null) { + // will only be called manually, when replacing the whole queue + indices.shuffle(_random); + return; + } + + indices.clear(); + _queueService!._queueFromConcatenatingAudioSource(); + FinampQueueInfo queueInfo = _queueService!.getQueue(); + indices = List.generate( + queueInfo.previousTracks.length + + 1 + + queueInfo.nextUp.length + + queueInfo.queue.length, + (i) => i); + if (indices.length <= 1) return; + indices.shuffle(_random); + + _queueService!.queueServiceLogger.finest("initialIndex: $initialIndex"); + + // log indices + String indicesString = ""; + for (int index in indices) { + indicesString += "$index, "; + } + _queueService!.queueServiceLogger + .finest("Shuffled indices: $indicesString"); + _queueService!.queueServiceLogger + .finest("Current Track: ${queueInfo.currentTrack}"); + + int nextUpLength = 0; + if (_queueService != null) { + nextUpLength = queueInfo.nextUp.length; + } + + const initialPos = 0; // current item will always be at the front + + // move current track and next up tracks to the front, pushing all other tracks back while keeping their order + // remove current track and next up tracks from indices and save them in a separate list + List currentTrackIndices = []; + for (int i = 0; i < 1 + nextUpLength; i++) { + currentTrackIndices + .add(indices.removeAt(indices.indexOf(initialIndex + i))); + } + // insert current track and next up tracks at the front + indices.insertAll(initialPos, currentTrackIndices); + + // log indices + indicesString = ""; + for (int index in indices) { + indicesString += "$index, "; + } + _queueService!.queueServiceLogger + .finest("Shuffled indices (swapped): $indicesString"); + } + + /// `index` is the linear index of the item in the ConcatenatingAudioSource + @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)"); + } + + // Offset indices after insertion point. + for (var i = 0; i < indices.length; i++) { + if (indices[i] >= index) { + indices[i] += count; + } + } + + // Insert new indices at the specified position. + final newIndices = List.generate(count, (i) => index + i); + indices.insertAll(insertionPoint, newIndices); + } + + @override + void removeRange(int start, int end) { + // log indices + String indicesString = ""; + for (int index in indices) { + indicesString += "$index, "; + } + _queueService!.queueServiceLogger + .finest("Shuffled indices before removing: $indicesString"); + 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; + } + } + // log indices + indicesString = ""; + for (int index in indices) { + indicesString += "$index, "; + } + _queueService!.queueServiceLogger + .finest("Shuffled indices after removing: $indicesString"); + } + + @override + void clear() { + indices.clear(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6af8561c2..8c7e75bc3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -434,6 +434,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_vibrate: + dependency: "direct main" + description: + name: flutter_vibrate + sha256: "9cc9b32cf52c90dd34c1cf396ed40010b2c74e69adbb0ff16005afa900971ad8" + url: "https://pub.dev" + source: hosted + version: "1.3.0" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 78f8725b1..befd74c90 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,9 +29,9 @@ dependencies: json_annotation: ^4.8.0 chopper: ^6.1.1 get_it: ^7.2.0 - just_audio: ^0.9.32 - audio_service: ^0.18.9 - audio_session: ^0.1.13 + just_audio: ^0.9.35 + audio_service: ^0.18.12 + audio_session: ^0.1.16 rxdart: ^0.27.7 simple_gesture_detector: ^0.2.0 flutter_downloader: @@ -82,6 +82,7 @@ dependencies: git: url: https://github.com/lamarios/locale_names.git ref: cea057c220f4ee7e09e8f1fc7036110245770948 + flutter_vibrate: ^1.3.0 dev_dependencies: flutter_test: