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