diff --git a/lib/components/AudioServiceSettingsScreen/loadQueueOnStartup_selector.dart b/lib/components/AudioServiceSettingsScreen/loadQueueOnStartup_selector.dart new file mode 100644 index 000000000..197afc40e --- /dev/null +++ b/lib/components/AudioServiceSettingsScreen/loadQueueOnStartup_selector.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../../models/finamp_models.dart'; + +class LoadQueueOnStartupSelector extends StatelessWidget { + const LoadQueueOnStartupSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.autoloadLastQueueOnStartup), + subtitle: Text( + AppLocalizations.of(context)!.autoloadLastQueueOnStartupSubtitle), + value: FinampSettingsHelper.finampSettings.autoloadLastQueueOnStartup, + onChanged: (value) => + FinampSettingsHelper.setAutoloadLastQueueOnStartup(value), + ); + }, + ); + } +} diff --git a/lib/components/MusicScreen/music_screen_drawer.dart b/lib/components/MusicScreen/music_screen_drawer.dart index 7afb55921..bf4b707bd 100644 --- a/lib/components/MusicScreen/music_screen_drawer.dart +++ b/lib/components/MusicScreen/music_screen_drawer.dart @@ -1,3 +1,4 @@ +import 'package:finamp/screens/queue_restore_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; @@ -80,6 +81,12 @@ class MusicScreenDrawer extends StatelessWidget { onTap: () => Navigator.of(context) .pushNamed(LogsScreen.routeName), ), + ListTile( + leading: const Icon(Icons.auto_delete), + title: Text(AppLocalizations.of(context)!.queuesScreen), + onTap: () => Navigator.of(context) + .pushNamed(QueueRestoreScreen.routeName), + ), ListTile( leading: const Icon(Icons.settings), title: Text(AppLocalizations.of(context)!.settings), diff --git a/lib/components/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart index a359b85e8..ede234ae1 100644 --- a/lib/components/PlayerScreen/queue_list.dart +++ b/lib/components/PlayerScreen/queue_list.dart @@ -18,6 +18,7 @@ import 'package:get_it/get_it.dart'; import 'package:rxdart/rxdart.dart'; import 'package:flutter_vibrate/flutter_vibrate.dart'; +import '../../services/current_album_image_provider.dart'; import '../album_image.dart'; import '../../models/jellyfin_models.dart' as jellyfin_models; import '../../services/process_artist.dart'; @@ -274,7 +275,8 @@ Future showQueueBottomSheet(BuildContext context) { builder: (context) { return Consumer( builder: (BuildContext context, WidgetRef ref, Widget? child) { - final imageTheme = ref.watch(playerScreenThemeProvider); + final imageTheme = + ref.watch(playerScreenThemeProvider(Theme.of(context).brightness)); return AnimatedTheme( duration: const Duration(milliseconds: 500), @@ -283,7 +285,7 @@ Future showQueueBottomSheet(BuildContext context) { colorScheme: imageTheme, brightness: Theme.of(context).brightness, iconTheme: Theme.of(context).iconTheme.copyWith( - color: imageTheme?.primary, + color: imageTheme.primary, ), ), child: DraggableScrollableSheet( @@ -680,12 +682,6 @@ class _CurrentTrackState extends State { 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; @@ -726,17 +722,8 @@ class _CurrentTrackState extends State { 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(), + imageListenable: currentAlbumImageProvider, ), Container( width: albumImageSize, @@ -1434,13 +1421,17 @@ class PreviousTracksSectionHeader extends SliverPersistentHeaderDelegate { return Icon( TablerIcons.chevron_up, size: 28.0, - color: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, ); } else { return Icon( TablerIcons.chevron_down, size: 28.0, - color: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, ); } }), diff --git a/lib/components/PlayerScreen/song_info.dart b/lib/components/PlayerScreen/song_info.dart index 964bf98b4..ba0be2bcb 100644 --- a/lib/components/PlayerScreen/song_info.dart +++ b/lib/components/PlayerScreen/song_info.dart @@ -1,26 +1,18 @@ -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'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; -import 'package:palette_generator/palette_generator.dart'; -import '../../generate_material_color.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'; import '../../services/jellyfin_api_helper.dart'; import '../../services/music_player_background_task.dart'; -import '../../services/player_screen_theme_provider.dart'; import 'song_name_content.dart'; import '../album_image.dart'; -import '../../at_contrast.dart'; /// Album image and song name/album etc. We do this in one widget to share a /// StreamBuilder and to make alignment easier. @@ -118,7 +110,7 @@ class _SongInfoState extends State { } } -class _PlayerScreenAlbumImage extends ConsumerWidget { +class _PlayerScreenAlbumImage extends StatelessWidget { _PlayerScreenAlbumImage({ Key? key, required this.queueItem, @@ -127,14 +119,7 @@ class _PlayerScreenAlbumImage extends ConsumerWidget { final FinampQueueItem queueItem; @override - Widget build(BuildContext context, WidgetRef ref) { - final queueService = GetIt.instance(); - - final item = queueItem.item.extras?["itemJson"] != null - ? jellyfin_models.BaseItemDto.fromJson( - queueItem.item.extras!["itemJson"] as Map) - : null; - + Widget build(BuildContext context) { return Container( decoration: BoxDecoration( boxShadow: [ @@ -155,62 +140,7 @@ class _PlayerScreenAlbumImage extends ConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 40), child: AlbumImage( - item: item, - // 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: 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) => - WidgetsBinding.instance.addPostFrameCallback((_) async { - // Don't do anything if the image from the callback is the same as - // the current provider's image. This is probably needed because of - // addPostFrameCallback shenanigans - if (imageProvider != null && - ref.read(currentAlbumImageProvider.notifier).state == - imageProvider) { - return; - } - - ref.read(currentAlbumImageProvider.notifier).state = imageProvider; - - if (imageProvider != null) { - final theme = Theme.of(context); - - final palette = - await PaletteGenerator.fromImageProvider( - imageProvider, - timeout: const Duration(milliseconds: 2000), - ); - - // Color accent = palette.dominantColor!.color; - Color accent = palette.vibrantColor?.color ?? palette.dominantColor?.color ?? const Color.fromARGB(255, 0, 164, 220); - - final lighter = theme.brightness == Brightness.dark; - - final background = Color.alphaBlend( - lighter - ? Colors.black.withOpacity(0.675) - : Colors.white.withOpacity(0.675), - accent); - - accent = accent.atContrast(4.5, background, lighter); - - ref.read(playerScreenThemeProvider.notifier).state = - ColorScheme.fromSwatch( - primarySwatch: generateMaterialColor(accent), - accentColor: accent, - brightness: theme.brightness, - ); - } - }), + imageListenable: currentAlbumImageProvider, ), ), ); diff --git a/lib/components/QueueRestoreScreen/queue_restore_tile.dart b/lib/components/QueueRestoreScreen/queue_restore_tile.dart new file mode 100644 index 000000000..d3e196590 --- /dev/null +++ b/lib/components/QueueRestoreScreen/queue_restore_tile.dart @@ -0,0 +1,82 @@ +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/downloads_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/queue_service.dart'; +import '../album_image.dart'; +import '../error_snackbar.dart'; + +class QueueRestoreTile extends StatelessWidget { + const QueueRestoreTile({Key? key, required this.info}) : super(key: key); + + final FinampStorableQueueInfo info; + + @override + Widget build(BuildContext context) { + final queuesBox = Hive.box("Queues"); + final downloadsHelper = GetIt.instance(); + final jellyfinApiHelper = GetIt.instance(); + final queueService = GetIt.instance(); + int remainingSongs = info.songCount - info.previousTracks.length; + Future track; + if (info.currentTrack == null) { + track = Future.value(null); + } else if (FinampSettingsHelper.finampSettings.isOffline) { + track = Future.value( + downloadsHelper.getDownloadedSong(info.currentTrack!)?.song); + } else { + track = jellyfinApiHelper + .getItemById(info.currentTrack!) + .then((x) => x, onError: (_) => null); + } + + return ListTile( + title: Text(AppLocalizations.of(context)!.queueRestoreTitle( + DateTime.fromMillisecondsSinceEpoch(info.creation))), + leading: FutureBuilder( + future: track, + builder: (context, snapshot) => AlbumImage(item: snapshot.data)), + isThreeLine: true, + //dense: true, + subtitle: FutureBuilder( + future: track, + initialData: null, + builder: (context, snapshot) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: ((snapshot.data?.name == null) + ? [] + : [ + // exclude subtitle line 1 if song name is null + Text( + AppLocalizations.of(context)! + .queueRestoreSubtitle1(snapshot.data!.name!), + overflow: TextOverflow.ellipsis) + ]) + + [ + Text(AppLocalizations.of(context)! + .queueRestoreSubtitle2(info.songCount, remainingSongs)) + ])), + trailing: IconButton( + icon: const Icon(Icons.arrow_circle_right_outlined), + onPressed: () { + var latest = queuesBox.get("latest"); + if (latest != null && latest.songCount != 0) { + queuesBox.put(latest.creation.toString(), latest); + } + BuildContext parentContext = Navigator.of(context).context; + queueService + .loadSavedQueue(info) + .catchError((x) => errorSnackbar(x, parentContext)); + Navigator.of(context).popUntil( + (route) => route.isFirst && !route.willHandlePopInternally); + }), + ); + } +} diff --git a/lib/components/album_image.dart b/lib/components/album_image.dart index e84d8c117..a6d78f32e 100644 --- a/lib/components/album_image.dart +++ b/lib/components/album_image.dart @@ -1,5 +1,5 @@ -import 'package:finamp/services/current_album_image_provider.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:octo_image/octo_image.dart'; import '../models/jellyfin_models.dart'; @@ -11,37 +11,32 @@ typedef ImageProviderCallback = void Function(ImageProvider? imageProvider); /// Aspect ratio 1 with a circular border radius of 4. If you don't want these /// customisations, use [BareAlbumImage] or get an [ImageProvider] directly /// through [AlbumImageProvider.init]. -class AlbumImage extends StatelessWidget { +class AlbumImage extends ConsumerWidget { const AlbumImage({ Key? key, this.item, - this.imageProviderCallback, - this.itemsToPrecache, + this.imageListenable, this.borderRadius, + this.placeholderBuilder, }) : super(key: key); /// The item to get an image for. final BaseItemDto? item; - /// A callback to get the image provider once it has been fetched. - final ImageProviderCallback? imageProviderCallback; - - /// A list of items to precache - final List? itemsToPrecache; + final ProviderListenable>? imageListenable; final BorderRadius? borderRadius; + final WidgetBuilder? placeholderBuilder; + static final defaultBorderRadius = BorderRadius.circular(4); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final borderRadius = this.borderRadius ?? defaultBorderRadius; - if (item == null || item!.imageId == null) { - if (imageProviderCallback != null) { - imageProviderCallback!(null); - } - + assert(item == null || imageListenable == null); + if ((item == null || item!.imageId == null) && imageListenable == null) { return ClipRRect( borderRadius: borderRadius, child: const AspectRatio( @@ -68,11 +63,13 @@ class AlbumImage extends StatelessWidget { (constraints.maxHeight * mediaQuery.devicePixelRatio).toInt(); return BareAlbumImage( - item: item!, - maxWidth: physicalWidth, - maxHeight: physicalHeight, - imageProviderCallback: imageProviderCallback, - itemsToPrecache: itemsToPrecache, + imageListenable: imageListenable ?? albumImageProvider(AlbumImageRequest( + item: item!, + maxWidth: physicalWidth, + maxHeight: physicalHeight, + )), + placeholderBuilder: + placeholderBuilder ?? BareAlbumImage.defaultPlaceholderBuilder, ); }), ), @@ -81,112 +78,44 @@ class AlbumImage extends StatelessWidget { } /// An [AlbumImage] without any of the padding or media size detection. -class BareAlbumImage extends StatefulWidget { +class BareAlbumImage extends ConsumerWidget { const BareAlbumImage({ Key? key, - required this.item, - this.maxWidth, - this.maxHeight, - this.errorBuilder, - this.placeholderBuilder, - this.imageProviderCallback, - this.itemsToPrecache, + required this.imageListenable, + this.errorBuilder = defaultErrorBuilder, + this.placeholderBuilder = defaultPlaceholderBuilder, }) : super(key: key); - final BaseItemDto item; - final int? maxWidth; - final int? maxHeight; - final WidgetBuilder? placeholderBuilder; - final OctoErrorBuilder? errorBuilder; - final ImageProviderCallback? imageProviderCallback; - - /// A list of items to precache - final List? itemsToPrecache; - - @override - State createState() => _BareAlbumImageState(); -} + final ProviderListenable> imageListenable; + final WidgetBuilder placeholderBuilder; + final OctoErrorBuilder errorBuilder; -class _BareAlbumImageState extends State { - late Future _albumImageContentFuture; - late WidgetBuilder _placeholderBuilder; - late OctoErrorBuilder _errorBuilder; + static Widget defaultPlaceholderBuilder(BuildContext context) { + return Container(color: Theme.of(context).cardColor); + } - @override - void initState() { - super.initState(); - _albumImageContentFuture = AlbumImageProvider.init( - widget.item, - maxWidth: widget.maxWidth, - maxHeight: widget.maxHeight, - itemsToPrecache: widget.itemsToPrecache, - context: context, - ); - _placeholderBuilder = widget.placeholderBuilder ?? - (context) => Container( - color: Theme.of(context).cardColor, - ); - _errorBuilder = widget.errorBuilder ?? - (context, _, __) => const _AlbumImageErrorPlaceholder(); + static Widget defaultErrorBuilder(BuildContext context, _, __) { + return const _AlbumImageErrorPlaceholder(); } - // We need to do this so that the image changes when dependencies change, such - // as when used in the player screen. @override - void didUpdateWidget(BareAlbumImage oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.item.imageId != oldWidget.item.imageId || - widget.maxWidth != oldWidget.maxWidth || - widget.maxHeight != oldWidget.maxHeight || - widget.itemsToPrecache != oldWidget.itemsToPrecache) { - _albumImageContentFuture = AlbumImageProvider.init( - widget.item, - maxWidth: widget.maxWidth, - maxHeight: widget.maxHeight, - itemsToPrecache: widget.itemsToPrecache, - context: context, + Widget build(BuildContext context, WidgetRef ref) { + AsyncValue image = ref.watch(imageListenable); + + if (image.hasValue && image.value != null) { + return OctoImage( + image: image.value!, + fit: BoxFit.cover, + placeholderBuilder: placeholderBuilder, + errorBuilder: errorBuilder, ); } - _placeholderBuilder = widget.placeholderBuilder ?? - (context) => Container( - color: Theme.of(context).cardColor, - ); - _errorBuilder = widget.errorBuilder ?? - (context, _, __) => const _AlbumImageErrorPlaceholder(); - } - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _albumImageContentFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - if (widget.imageProviderCallback != null) { - widget.imageProviderCallback!(snapshot.data!); - } - - return OctoImage( - image: snapshot.data!, - fit: BoxFit.cover, - placeholderBuilder: _placeholderBuilder, - errorBuilder: _errorBuilder, - ); - } - - if (snapshot.hasError) { - if (widget.imageProviderCallback != null) { - widget.imageProviderCallback!(null); - } - return const _AlbumImageErrorPlaceholder(); - } - - if (widget.imageProviderCallback != null) { - widget.imageProviderCallback!(null); - } + if (image.hasError) { + return const _AlbumImageErrorPlaceholder(); + } - return Builder(builder: _placeholderBuilder); - }, - ); + return Builder(builder: placeholderBuilder); } } diff --git a/lib/components/now_playing_bar.dart b/lib/components/now_playing_bar.dart index 9095f32ba..2b43d4429 100644 --- a/lib/components/now_playing_bar.dart +++ b/lib/components/now_playing_bar.dart @@ -1,24 +1,17 @@ -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/current_album_image_provider.dart'; import '../services/finamp_settings_helper.dart'; import '../services/media_state_stream.dart'; import 'album_image.dart'; @@ -26,478 +19,486 @@ 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 ConsumerWidget { const NowPlayingBar({ Key? key, }) : super(key: key); - @override - 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); + Widget buildLoadingQueueBar(BuildContext context, Function()? retryCallback) { + const elevation = 16.0; + const albumImageSize = 70.0; + + return SimpleGestureDetector( + onVerticalSwipe: (direction) { + if (direction == SwipeDirection.up && retryCallback != null) { + retryCallback(); + } + }, + onTap: retryCallback, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, bottom: 12.0, right: 12.0), + child: Material( + shadowColor: + Theme.of(context).colorScheme.primary.withOpacity(0.75), + 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( + child: 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)), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: albumImageSize, + height: albumImageSize, + decoration: const ShapeDecoration( + shape: Border(), + color: Color.fromRGBO(0, 0, 0, 0.3), + ), + child: (retryCallback != null) + ? const Icon(Icons.refresh, size: albumImageSize) + : const Center( + child: CircularProgressIndicator.adaptive())), + Expanded( + child: Container( + height: albumImageSize, + padding: const EdgeInsets.only(left: 12, right: 4), + alignment: Alignment.centerLeft, + child: Text((retryCallback != null) + ? AppLocalizations.of(context)!.queueRetryMessage + : AppLocalizations.of(context)! + .queueLoadingMessage)), + ), + ], + ), + ), + )), + ), + )); + } + Widget buildNowPlayingBar( + BuildContext context, FinampQueueItem currentTrack) { const elevation = 16.0; const horizontalPadding = 8.0; const albumImageSize = 70.0; - // final color = Theme.of(context).bottomNavigationBarTheme.backgroundColor; final audioHandler = GetIt.instance(); - final queueService = GetIt.instance(); Duration? playbackPosition; - 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 - .primary - .withOpacity(0.75), - 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)), - ), + final currentTrackBaseItem = currentTrack.item.extras?["itemJson"] != null + ? jellyfin_models.BaseItemDto.fromJson( + currentTrack.item.extras!["itemJson"] as Map) + : null; + return SimpleGestureDetector( + onVerticalSwipe: (direction) { + if (direction == SwipeDirection.up) { + Navigator.of(context).pushNamed(PlayerScreen.routeName); + } + }, + onTap: () => Navigator.of(context).pushNamed(PlayerScreen.routeName), + child: Padding( + padding: const EdgeInsets.only(left: 12.0, bottom: 12.0, right: 12.0), + child: Material( + shadowColor: Theme.of(context).colorScheme.primary.withOpacity(0.75), + 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.where((event) => event.mediaItem != null), + initialData: MediaState(audioHandler.mediaItem.valueOrNull, + audioHandler.playbackState.value), + builder: (context, snapshot) { + final MediaState mediaState = snapshot.data!; + // If we have a media item and the player hasn't finished, show + // the now playing bar. + if (mediaState.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)), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + AlbumImage( + placeholderBuilder: (_) => + const SizedBox.shrink(), + imageListenable: currentAlbumImageProvider, + borderRadius: BorderRadius.zero, + ), + Container( + width: albumImageSize, + height: albumImageSize, + decoration: const ShapeDecoration( + shape: Border(), + color: Color.fromRGBO(0, 0, 0, 0.3), ), - 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(), + 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, ), - Container( - width: albumImageSize, - height: albumImageSize, - decoration: - const ShapeDecoration( - shape: Border(), - color: Color.fromRGBO( - 0, 0, 0, 0.3), + color: Colors.white, + )), + ], + ), + Expanded( + child: Stack( + children: [ + Positioned( + left: 0, + top: 0, + child: StreamBuilder( + stream: AudioService.position, + initialData: 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), + ), ), - 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( + ), + ); + } 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: [ - 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(); - } - }), + 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( - 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, - ), - ), - ], - ) - ], - ), - ], - ), + 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( - 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! + StreamBuilder( + stream: AudioService + .position, + initialData: + 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 - ?.extras![ - "itemJson"] = - currentTrackBaseItem - .toJson(); - }, + ?.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; + mediaState.mediaItem + ?.extras!["itemJson"] = + currentTrackBaseItem + .toJson(); + }, + ), + ), + ], + ), + ], ), - ); - } 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, + ); + } + }, + ), + ), + ), + ), ), ); } + + @override + Widget build(BuildContext context, WidgetRef ref) { + // BottomNavBar's default elevation is 8 (https://api.flutter.dev/flutter/material/BottomNavigationBar/elevation.html) + final queueService = GetIt.instance(); + var imageTheme = + ref.watch(playerScreenThemeProvider(Theme.of(context).brightness)); + + return Hero( + tag: "nowplaying", + createRectTween: (from, to) => RectTween(begin: from, end: from), + child: AnimatedTheme( + // immediately apply new theme if in background to avoid showing wrong theme during transition + duration: ModalRoute.of(context)!.isCurrent + ? const Duration(milliseconds: 1000) + : const Duration(milliseconds: 0), + data: ThemeData( + fontFamily: "LexendDeca", + colorScheme: imageTheme.copyWith( + brightness: Theme.of(context).brightness, + ), + iconTheme: Theme.of(context).iconTheme.copyWith( + color: imageTheme.primary, + ), + ), + child: StreamBuilder( + stream: queueService.getQueueStream(), + initialData: queueService.getQueue(), + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.data!.saveState == SavedQueueState.loading) { + return buildLoadingQueueBar(context, null); + } else if (snapshot.hasData && + snapshot.data!.saveState == SavedQueueState.failed) { + return buildLoadingQueueBar( + context, queueService.retryQueueLoad); + } else if (snapshot.hasData && + snapshot.data!.currentTrack != null) { + return buildNowPlayingBar( + context, snapshot.data!.currentTrack!); + } else { + return const SizedBox( + width: 0, + height: 0, + ); + } + }), + )); + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 144b141be..69ac7cbc1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -585,6 +585,10 @@ "@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" }, + "savedQueue": "Saved Queue", + "@savedQueue": { + "description": "Title for the queue source for tracks were previously in the queue before an app restart and have been reloaded" + }, "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\"", @@ -598,6 +602,62 @@ "@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" }, + "queuesScreen": "Restore Now Playing", + "@queuesScreen": { + "description": "Title for the screen where older now playing queues can be restored" + }, + "queueRestoreButtonLabel": "Restore", + "@queueRestoreButtonLabel": { + "description": "Button to restore archived now playing queue, overwriting current queue" + }, + "queueRestoreTitle": "Saved {date}", + "@queueRestoreTitle": { + "description": "Description of when a saved queue was saved", + "placeholders": { + "date" : { + "type": "DateTime", + "format": "yyy-MM-dd hh:mm", + "isCustomDateFormat": "true" + } + } + }, + "queueRestoreSubtitle1": "Playing: {song}", + "@queueRestoreSubtitle1": { + "description": "Description of playing song in a saved queue", + "placeholders": { + "song": { + "type": "String" + } + } + }, + "queueRestoreSubtitle2": "{count,plural,=1{1 Song} other{{count} Songs}}, {remaining} Unplayed", + "@queueRestoreSubtitle2": { + "description": "Description of length of a saved queue", + "placeholders": { + "count": { + "type": "int" + }, + "remaining": { + "type": "int" + } + } + }, + "queueLoadingMessage": "Restoring queue...", + "@queueLoadingMessage": { + "description": "Message displayed on now-playing bar when a saved queue is loading." + }, + "queueRetryMessage": "Failed to restore queue. Retry?", + "@queueRetryMessage": { + "description": "Message displayed on now-playing bar when all items in a saved queue fail to load." + }, + "autoloadLastQueueOnStartup": "Auto-Restore Last Queue", + "@autoloadLastQueueOnStartup": { + "description": "Setting to restore last queue on startup" + }, + "autoloadLastQueueOnStartupSubtitle": "Upon app startup, attempt to restore the last played queue.", + "@autoloadLastQueueOnStartupSubtitle": { + "description": "Description of the setting to restore last queue on startup" + }, "topSongs": "Top Songs", "@topSongs": {}, "playCountInline": " ยท {playCount} plays", diff --git a/lib/main.dart b/lib/main.dart index 7f86c5c1b..d4ce9beb2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ 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/screens/queue_restore_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'; @@ -166,6 +167,11 @@ Future setupHive() async { Hive.registerAdapter(ThemeModeAdapter()); Hive.registerAdapter(LocaleAdapter()); Hive.registerAdapter(FinampLoopModeAdapter()); + Hive.registerAdapter(FinampStorableQueueInfoAdapter()); + Hive.registerAdapter(QueueItemSourceAdapter()); + Hive.registerAdapter(QueueItemSourceTypeAdapter()); + Hive.registerAdapter(QueueItemSourceNameAdapter()); + Hive.registerAdapter(QueueItemSourceNameTypeAdapter()); await Future.wait([ Hive.openBox("DownloadedParents"), Hive.openBox("DownloadedItems"), @@ -176,6 +182,7 @@ Future setupHive() async { Hive.openBox("DownloadedImages"), Hive.openBox("DownloadedImageIds"), Hive.openBox("ThemeMode"), + Hive.openBox("Queues"), Hive.openBox(LocaleHelper.boxName), ]); @@ -314,6 +321,8 @@ class Finamp extends StatelessWidget { PlaybackHistoryScreen.routeName: (context) => const PlaybackHistoryScreen(), LogsScreen.routeName: (context) => const LogsScreen(), + QueueRestoreScreen.routeName: (context) => + const QueueRestoreScreen(), SettingsScreen.routeName: (context) => const SettingsScreen(), TranscodingSettingsScreen.routeName: (context) => diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index 2d29203ee..2fe80701f 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -1,6 +1,5 @@ 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'; @@ -88,6 +87,7 @@ class FinampSettings { required this.tabSortBy, required this.tabSortOrder, this.loopMode = _defaultLoopMode, + this.autoloadLastQueueOnStartup = true, }); @HiveField(0) @@ -174,6 +174,9 @@ class FinampSettings { @HiveField(22, defaultValue: _defaultLoopMode) FinampLoopMode loopMode; + @HiveField(23, defaultValue: true) + bool autoloadLastQueueOnStartup; + static Future create() async { final internalSongDir = await getInternalSongDir(); final downloadLocation = DownloadLocation.create( @@ -668,6 +671,8 @@ enum QueueItemSourceNameType { nextUp, @HiveField(6) tracksFormerNextUp, + @HiveField(7) + savedQueue, } @HiveType(typeId: 56) @@ -701,6 +706,8 @@ class QueueItemSourceName { return AppLocalizations.of(context)!.nextUp; case QueueItemSourceNameType.tracksFormerNextUp: return AppLocalizations.of(context)!.tracksFormerNextUp; + case QueueItemSourceNameType.savedQueue: + return AppLocalizations.of(context)!.savedQueue; } } } @@ -726,6 +733,12 @@ class FinampQueueItem { @HiveField(3) QueueItemQueueType type; + + BaseItemDto? get baseItem { + return (item.extras?["itemJson"] != null) + ? BaseItemDto.fromJson(item.extras!["itemJson"] as Map) + : null; + } } @HiveType(typeId: 58) @@ -762,6 +775,7 @@ class FinampQueueInfo { required this.nextUp, required this.queue, required this.source, + required this.saveState, }); @HiveField(0) @@ -778,6 +792,9 @@ class FinampQueueInfo { @HiveField(4) QueueItemSource source; + + @HiveField(5) + SavedQueueState saveState; } @HiveType(typeId: 60) @@ -797,3 +814,81 @@ class FinampHistoryItem { @HiveField(2) DateTime? endTime; } + +@HiveType(typeId: 61) +class FinampStorableQueueInfo { + FinampStorableQueueInfo({ + required this.previousTracks, + required this.currentTrack, + required this.currentTrackSeek, + required this.nextUp, + required this.queue, + required this.creation, + required this.source, + }); + + FinampStorableQueueInfo.fromQueueInfo(FinampQueueInfo info, int? seek) + : previousTracks = info.previousTracks + .map((track) => track.item.extras?["itemJson"]["Id"]) + .toList(), + currentTrack = info.currentTrack?.item.extras?["itemJson"]["Id"], + currentTrackSeek = seek, + nextUp = info.nextUp + .map((track) => track.item.extras?["itemJson"]["Id"]) + .toList(), + queue = info.queue + .map((track) => track.item.extras?["itemJson"]["Id"]) + .toList(), + creation = DateTime.now().millisecondsSinceEpoch, + source = info.source; + + @HiveField(0) + List previousTracks; + + @HiveField(1) + String? currentTrack; + + @HiveField(2) + int? currentTrackSeek; + + @HiveField(3) + List nextUp; + + @HiveField(4) + List queue; + + @HiveField(5) + // timestamp, milliseconds since epoch + int creation; + + @HiveField(6) + QueueItemSource? source; + + @override + String toString() { + return "previous:$previousTracks current:$currentTrack seek:$currentTrackSeek next:$nextUp queue:$queue"; + } + + int get songCount { + return previousTracks.length + + ((currentTrack == null) ? 0 : 1) + + nextUp.length + + queue.length; + } +} + +@HiveType(typeId: 62) +enum SavedQueueState { + @HiveField(0) + preInit, + @HiveField(1) + init, + @HiveField(2) + loading, + @HiveField(3) + saving, + @HiveField(4) + failed, + @HiveField(5) + pendingSave, +} diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index 4859314b5..23079c9cd 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -102,13 +102,15 @@ class FinampSettingsAdapter extends TypeAdapter { loopMode: fields[22] == null ? FinampLoopMode.all : fields[22] as FinampLoopMode, + autoloadLastQueueOnStartup: + fields[23] == null ? true : fields[23] as bool, )..disableGesture = fields[19] == null ? false : fields[19] as bool; } @override void write(BinaryWriter writer, FinampSettings obj) { writer - ..writeByte(23) + ..writeByte(24) ..writeByte(0) ..write(obj.isOffline) ..writeByte(1) @@ -154,7 +156,9 @@ class FinampSettingsAdapter extends TypeAdapter { ..writeByte(21) ..write(obj.tabSortOrder) ..writeByte(22) - ..write(obj.loopMode); + ..write(obj.loopMode) + ..writeByte(23) + ..write(obj.autoloadLastQueueOnStartup); } @override @@ -542,13 +546,14 @@ class FinampQueueInfoAdapter extends TypeAdapter { nextUp: (fields[2] as List).cast(), queue: (fields[3] as List).cast(), source: fields[4] as QueueItemSource, + saveState: fields[5] as SavedQueueState, ); } @override void write(BinaryWriter writer, FinampQueueInfo obj) { writer - ..writeByte(5) + ..writeByte(6) ..writeByte(0) ..write(obj.previousTracks) ..writeByte(1) @@ -558,7 +563,9 @@ class FinampQueueInfoAdapter extends TypeAdapter { ..writeByte(3) ..write(obj.queue) ..writeByte(4) - ..write(obj.source); + ..write(obj.source) + ..writeByte(5) + ..write(obj.saveState); } @override @@ -612,6 +619,59 @@ class FinampHistoryItemAdapter extends TypeAdapter { typeId == other.typeId; } +class FinampStorableQueueInfoAdapter + extends TypeAdapter { + @override + final int typeId = 61; + + @override + FinampStorableQueueInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FinampStorableQueueInfo( + previousTracks: (fields[0] as List).cast(), + currentTrack: fields[1] as String?, + currentTrackSeek: fields[2] as int?, + nextUp: (fields[3] as List).cast(), + queue: (fields[4] as List).cast(), + creation: fields[5] as int, + source: fields[6] as QueueItemSource?, + ); + } + + @override + void write(BinaryWriter writer, FinampStorableQueueInfo obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.previousTracks) + ..writeByte(1) + ..write(obj.currentTrack) + ..writeByte(2) + ..write(obj.currentTrackSeek) + ..writeByte(3) + ..write(obj.nextUp) + ..writeByte(4) + ..write(obj.queue) + ..writeByte(5) + ..write(obj.creation) + ..writeByte(6) + ..write(obj.source); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampStorableQueueInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + class TabContentTypeAdapter extends TypeAdapter { @override final int typeId = 36; @@ -818,10 +878,16 @@ class QueueItemSourceTypeAdapter extends TypeAdapter { case 10: return QueueItemSourceType.nextUp; case 11: - return QueueItemSourceType.formerNextUp; + return QueueItemSourceType.nextUpAlbum; case 12: - return QueueItemSourceType.downloads; + return QueueItemSourceType.nextUpPlaylist; case 13: + return QueueItemSourceType.nextUpArtist; + case 14: + return QueueItemSourceType.formerNextUp; + case 15: + return QueueItemSourceType.downloads; + case 16: return QueueItemSourceType.unknown; default: return QueueItemSourceType.album; @@ -864,15 +930,24 @@ class QueueItemSourceTypeAdapter extends TypeAdapter { case QueueItemSourceType.nextUp: writer.writeByte(10); break; - case QueueItemSourceType.formerNextUp: + case QueueItemSourceType.nextUpAlbum: writer.writeByte(11); break; - case QueueItemSourceType.downloads: + case QueueItemSourceType.nextUpPlaylist: writer.writeByte(12); break; - case QueueItemSourceType.unknown: + case QueueItemSourceType.nextUpArtist: writer.writeByte(13); break; + case QueueItemSourceType.formerNextUp: + writer.writeByte(14); + break; + case QueueItemSourceType.downloads: + writer.writeByte(15); + break; + case QueueItemSourceType.unknown: + writer.writeByte(16); + break; } } @@ -958,6 +1033,8 @@ class QueueItemSourceNameTypeAdapter return QueueItemSourceNameType.nextUp; case 6: return QueueItemSourceNameType.tracksFormerNextUp; + case 7: + return QueueItemSourceNameType.savedQueue; default: return QueueItemSourceNameType.preTranslated; } @@ -987,6 +1064,9 @@ class QueueItemSourceNameTypeAdapter case QueueItemSourceNameType.tracksFormerNextUp: writer.writeByte(6); break; + case QueueItemSourceNameType.savedQueue: + writer.writeByte(7); + break; } } @@ -1001,6 +1081,65 @@ class QueueItemSourceNameTypeAdapter typeId == other.typeId; } +class SavedQueueStateAdapter extends TypeAdapter { + @override + final int typeId = 62; + + @override + SavedQueueState read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SavedQueueState.preInit; + case 1: + return SavedQueueState.init; + case 2: + return SavedQueueState.loading; + case 3: + return SavedQueueState.saving; + case 4: + return SavedQueueState.failed; + case 5: + return SavedQueueState.pendingSave; + default: + return SavedQueueState.preInit; + } + } + + @override + void write(BinaryWriter writer, SavedQueueState obj) { + switch (obj) { + case SavedQueueState.preInit: + writer.writeByte(0); + break; + case SavedQueueState.init: + writer.writeByte(1); + break; + case SavedQueueState.loading: + writer.writeByte(2); + break; + case SavedQueueState.saving: + writer.writeByte(3); + break; + case SavedQueueState.failed: + writer.writeByte(4); + break; + case SavedQueueState.pendingSave: + writer.writeByte(5); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SavedQueueStateAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** diff --git a/lib/screens/audio_service_settings_screen.dart b/lib/screens/audio_service_settings_screen.dart index 53d5f9887..17d26a196 100644 --- a/lib/screens/audio_service_settings_screen.dart +++ b/lib/screens/audio_service_settings_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../components/AudioServiceSettingsScreen/buffer_duration_list_tile.dart'; +import '../components/AudioServiceSettingsScreen/loadQueueOnStartup_selector.dart'; import '../components/AudioServiceSettingsScreen/stop_foreground_selector.dart'; import '../components/AudioServiceSettingsScreen/song_shuffle_item_count_editor.dart'; @@ -24,6 +25,7 @@ class AudioServiceSettingsScreen extends StatelessWidget { if (Platform.isAndroid) const StopForegroundSelector(), const SongShuffleItemCountEditor(), const BufferDurationListTile(), + const LoadQueueOnStartupSelector() ], ), ), diff --git a/lib/screens/blurred_player_screen_background.dart b/lib/screens/blurred_player_screen_background.dart index be39b6035..9ad3d18c2 100644 --- a/lib/screens/blurred_player_screen_background.dart +++ b/lib/screens/blurred_player_screen_background.dart @@ -19,32 +19,35 @@ class BlurredPlayerScreenBackground extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final imageProvider = ref.watch(currentAlbumImageProvider); + final imageProvider = ref.watch(currentAlbumImageProvider).value; - 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, + return AnimatedSwitcher( + duration: const Duration(milliseconds: 1000), + child: ClipRect( + key: ObjectKey(imageProvider), + 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), + ), ), - child: SizedBox.expand(child: child), ), - ), - ), - ); + )); } } diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart index 9b6d144ec..917d6174c 100644 --- a/lib/screens/music_screen.dart +++ b/lib/screens/music_screen.dart @@ -1,4 +1,5 @@ import 'package:finamp/screens/playback_history_screen.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'; @@ -40,6 +41,7 @@ class _MusicScreenState extends State final _audioServiceHelper = GetIt.instance(); final _finampUserHelper = GetIt.instance(); final _jellyfinApiHelper = GetIt.instance(); + final _queueService = GetIt.instance(); void _stopSearching() { setState(() { @@ -161,6 +163,9 @@ class _MusicScreenState extends State @override Widget build(BuildContext context) { + _queueService + .performInitialQueueLoad() + .catchError((x) => errorSnackbar(x, context)); if (_tabController == null) { _buildTabController(); } diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index ae5e1e06c..9d06e142e 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -3,7 +3,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'; import 'package:simple_gesture_detector/simple_gesture_detector.dart'; import '../components/PlayerScreen/control_area.dart'; @@ -11,11 +10,7 @@ import '../components/PlayerScreen/song_info.dart'; import '../components/PlayerScreen/queue_button.dart'; 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'; @@ -29,17 +24,18 @@ class PlayerScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final imageTheme = ref.watch(playerScreenThemeProvider); + final imageTheme = + ref.watch(playerScreenThemeProvider(Theme.of(context).brightness)); return AnimatedTheme( duration: const Duration(milliseconds: 500), data: ThemeData( fontFamily: "LexendDeca", - colorScheme: imageTheme?.copyWith( + colorScheme: imageTheme.copyWith( brightness: Theme.of(context).brightness, ), iconTheme: Theme.of(context).iconTheme.copyWith( - color: imageTheme?.primary, + color: imageTheme.primary, ), ), child: const _PlayerScreenContent(), diff --git a/lib/screens/queue_restore_screen.dart b/lib/screens/queue_restore_screen.dart new file mode 100644 index 000000000..a2fdc6741 --- /dev/null +++ b/lib/screens/queue_restore_screen.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../components/QueueRestoreScreen/queue_restore_tile.dart'; +import '../models/finamp_models.dart'; + +class QueueRestoreScreen extends StatelessWidget { + const QueueRestoreScreen({Key? key}) : super(key: key); + + static const routeName = "/queues"; + + @override + Widget build(BuildContext context) { + final _queuesBox = Hive.box("Queues"); + var queueMap = _queuesBox.toMap(); + queueMap.remove("latest"); + var queueList = queueMap.values.toList(); + queueList.sort((x, y) => y.creation - x.creation); + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.queuesScreen), + ), + body: Scrollbar( + child: ListView.builder( + padding: const EdgeInsets.only( + left: 0.0, right: 0.0, top: 30.0, bottom: 45.0), + itemCount: queueList.length, + itemBuilder: (context, index) { + return QueueRestoreTile(info: queueList.elementAt(index)); + }, + ), + ), + ); + } +} diff --git a/lib/services/album_image_provider.dart b/lib/services/album_image_provider.dart index 2a5a4649c..d12c24f64 100644 --- a/lib/services/album_image_provider.dart +++ b/lib/services/album_image_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import '../models/jellyfin_models.dart'; @@ -6,64 +7,72 @@ import 'downloads_helper.dart'; import 'finamp_settings_helper.dart'; import 'jellyfin_api_helper.dart'; -/// A class that handles returning ImageProviders for Jellyfin items. This class -/// only has one static function to handle this, and has no constructors. It's a -/// bit of a jank way to do this, so if you know a better way, please let me -/// know :) -class AlbumImageProvider { - static Future init( - BaseItemDto item, { - int? maxWidth, - int? maxHeight, - List? itemsToPrecache, - BuildContext? context, - }) async { - assert(itemsToPrecache == null ? true : context != null); - if (item.imageId == null) { - return null; - } +class AlbumImageRequest { + const AlbumImageRequest({ + required this.item, + this.maxWidth, + this.maxHeight, + }) : super(); - if (itemsToPrecache != null) { - for (final itemToPrecache in itemsToPrecache) { - init(itemToPrecache, maxWidth: maxWidth, maxHeight: maxHeight) - .then((value) { - if (value != null) { - precacheImage(value, context!); - } - }); - } - } + final BaseItemDto item; + + final int? maxWidth; - final jellyfinApiHelper = GetIt.instance(); - final downloadsHelper = GetIt.instance(); + final int? maxHeight; - final downloadedImage = downloadsHelper.getDownloadedImage(item); + @override + bool operator ==(Object other) { + return other is AlbumImageRequest && + other.maxHeight == maxHeight && + other.maxWidth == maxWidth && + other.item.id == item.id; + } + + @override + int get hashCode => Object.hash(item.id, maxHeight, maxWidth); +} - if (downloadedImage == null) { - if (FinampSettingsHelper.finampSettings.isOffline) { - return null; - } +final AutoDisposeFutureProviderFamily + albumImageProvider = FutureProvider.autoDispose + .family((ref, request) async { + if (request.item.imageId == null) { + return null; + } - Uri? imageUrl = jellyfinApiHelper.getImageUrl( - item: item, - maxWidth: maxWidth, - maxHeight: maxHeight, - ); + final jellyfinApiHelper = GetIt.instance(); + final downloadsHelper = GetIt.instance(); - if (imageUrl == null) { - return null; - } + final downloadedImage = downloadsHelper.getDownloadedImage(request.item); - return NetworkImage(imageUrl.toString()); + if (downloadedImage == null) { + if (FinampSettingsHelper.finampSettings.isOffline) { + return null; } - if (await downloadsHelper.verifyDownloadedImage(downloadedImage)) { - return FileImage(downloadedImage.file); + Uri? imageUrl = jellyfinApiHelper.getImageUrl( + item: request.item, + maxWidth: request.maxWidth, + maxHeight: request.maxHeight, + ); + + if (imageUrl == null) { + return null; } - // If we've got this far, the download image has failed to verify. - // We recurse, which will either return a NetworkImage or an error depending - // on if the app is offline. - return init(item, maxWidth: maxWidth, maxHeight: maxHeight); + return NetworkImage(imageUrl.toString()); } -} + + if (await downloadsHelper.verifyDownloadedImage(downloadedImage)) { + return FileImage(downloadedImage.file); + } + + // If we've got this far, the download image has failed to verify. + // We recurse, which will either return a NetworkImage or an error depending + // on if the app is offline. + return ref + .read(albumImageProvider(AlbumImageRequest( + item: request.item, + maxWidth: request.maxWidth, + maxHeight: request.maxHeight))) + .value; +}); diff --git a/lib/services/current_album_image_provider.dart b/lib/services/current_album_image_provider.dart index be04d170c..691c21419 100644 --- a/lib/services/current_album_image_provider.dart +++ b/lib/services/current_album_image_provider.dart @@ -1,7 +1,46 @@ +import 'dart:async'; + +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'album_image_provider.dart'; /// Provider to handle syncing up the current playing item's image provider. /// Used on the player screen to sync up loading the blurred background. -final currentAlbumImageProvider = - StateProvider.autoDispose((_) => null); +final currentAlbumImageProvider = FutureProvider((ref) async { + final List precacheItems = + GetIt.instance().getNextXTracksInQueue(3, reverse: 1); + for (final itemToPrecache in precacheItems) { + BaseItemDto? base = itemToPrecache.baseItem; + if (base != null) { + final request = AlbumImageRequest(item: base); + unawaited(ref.read(albumImageProvider(request).future).then((value) { + if (value != null) { + // Cache the returned image + var stream = + value.resolve(const ImageConfiguration(devicePixelRatio: 1.0)); + var listener = ImageStreamListener((image, synchronousCall) {}); + ref.onDispose(() { + stream.removeListener(listener); + }); + stream.addListener(listener); + } + })); + } + } + + final currentTrack = ref.watch(currentSongProvider).value?.baseItem; + if (currentTrack != null) { + final request = AlbumImageRequest( + item: currentTrack, + ); + return ref.read(albumImageProvider(request).future); + } + return null; +}); + +final currentSongProvider = StreamProvider( + (_) => GetIt.instance().getCurrentTrackStream()); diff --git a/lib/services/finamp_settings_helper.dart b/lib/services/finamp_settings_helper.dart index b403b4985..2bcf654ad 100644 --- a/lib/services/finamp_settings_helper.dart +++ b/lib/services/finamp_settings_helper.dart @@ -1,4 +1,3 @@ -import 'package:finamp/services/queue_service.dart'; import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -109,6 +108,13 @@ class FinampSettingsHelper { .put("FinampSettings", finampSettingsTemp); } + static void setAutoloadLastQueueOnStartup(bool autoloadLastQueueOnStartup) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.autoloadLastQueueOnStartup = autoloadLastQueueOnStartup; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + static void setSongShuffleItemCount(int songShuffleItemCount) { FinampSettings finampSettingsTemp = finampSettings; finampSettingsTemp.songShuffleItemCount = songShuffleItemCount; diff --git a/lib/services/jellyfin_api.chopper.dart b/lib/services/jellyfin_api.chopper.dart index 50a645877..144e7b831 100644 --- a/lib/services/jellyfin_api.chopper.dart +++ b/lib/services/jellyfin_api.chopper.dart @@ -91,6 +91,7 @@ class _$JellyfinApi extends JellyfinApi { String? albumArtistIds, String? artistIds, String? albumIds, + String? ids, bool? recursive, String? sortBy, String? sortOrder, @@ -108,6 +109,7 @@ class _$JellyfinApi extends JellyfinApi { 'AlbumArtistIds': albumArtistIds, 'ArtistIds': artistIds, 'AlbumIds': albumIds, + 'ids': ids, 'Recursive': recursive, 'SortBy': sortBy, 'SortOrder': sortOrder, diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart index e3f841ece..26f060895 100644 --- a/lib/services/jellyfin_api.dart +++ b/lib/services/jellyfin_api.dart @@ -78,6 +78,8 @@ abstract class JellyfinApi extends ChopperService { /// containing the specified album id. @Query("AlbumIds") String? albumIds, + @Query("ids") String? ids, + /// When searching within folders, this determines whether or not the search /// will be recursive. true/false. @Query("Recursive") bool? recursive, diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart index d2a774b00..fc4d783fd 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -27,7 +27,8 @@ class JellyfinApiHelper { String? sortBy, String? sortOrder, String? searchTerm, - required bool isGenres, + List? itemIds, + bool? isGenres, // Unused? String? filters, /// The record index to start at. All items with a lower index will be @@ -124,6 +125,7 @@ class JellyfinApiHelper { filters: filters, startIndex: startIndex, limit: limit, + ids: itemIds?.join(","), ); } diff --git a/lib/services/player_screen_theme_provider.dart b/lib/services/player_screen_theme_provider.dart index fccecd9d3..055bcccea 100644 --- a/lib/services/player_screen_theme_provider.dart +++ b/lib/services/player_screen_theme_provider.dart @@ -1,5 +1,84 @@ +import 'dart:async'; + +import 'package:finamp/at_contrast.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:palette_generator/palette_generator.dart'; + +import '../generate_material_color.dart'; +import 'current_album_image_provider.dart'; + +final AutoDisposeProviderFamily + playerScreenThemeProvider = + Provider.family.autoDispose((ref, brightness) { + ColorScheme? scheme = + ref.watch(playerScreenThemeNullableProvider(brightness)).value; + if (scheme == null) { + Color accent = brightness == Brightness.dark + ? const Color.fromARGB(255, 133, 133, 133) + : const Color.fromARGB(255, 61, 61, 61); + + return ColorScheme.fromSwatch( + primarySwatch: generateMaterialColor(accent), + accentColor: accent, + brightness: brightness, + ); + } else { + return scheme; + } +}); + +final AutoDisposeFutureProviderFamily + playerScreenThemeNullableProvider = FutureProvider.family + .autoDispose((ref, brightness) async { + ImageProvider? image = await ref.watch(currentAlbumImageProvider.future); + if (image == null) { + return null; + } + + Logger("colorProvider").fine("Re-theming based on image $image"); + Completer completer = Completer(); + ImageStream stream = + image.resolve(const ImageConfiguration(devicePixelRatio: 1.0)); + ImageStreamListener? listener; + + listener = ImageStreamListener((image, synchronousCall) async { + stream.removeListener(listener!); + // Use fromImage instead of fromImageProvider to better handle error case + final PaletteGenerator palette = + await PaletteGenerator.fromImage(image.image); + + Color accent = palette.vibrantColor?.color ?? + palette.dominantColor?.color ?? + const Color.fromARGB(255, 0, 164, 220); + + final lighter = brightness == Brightness.dark; + + final background = Color.alphaBlend( + lighter + ? Colors.black.withOpacity(0.675) + : Colors.white.withOpacity(0.675), + accent); + + accent = accent.atContrast(4.5, background, lighter); + + completer.complete(ColorScheme.fromSwatch( + primarySwatch: generateMaterialColor(accent), + accentColor: accent, + brightness: brightness, + )); + }, onError: (_, __) { + stream.removeListener(listener!); + completer.complete(ColorScheme.fromSeed( + seedColor: const Color.fromARGB(255, 0, 164, 220), + brightness: brightness)); + }); + + ref.onDispose(() { + stream.removeListener(listener!); + }); -final playerScreenThemeProvider = - StateProvider.autoDispose((_) => null); + stream.addListener(listener); + return completer.future; +}); diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 65d4fc9ce..1525b45fe 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -8,9 +8,11 @@ import 'package:get_it/get_it.dart'; import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; +import 'package:collection/collection.dart'; import 'package:finamp/models/finamp_models.dart'; import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; +import 'package:hive_flutter/hive_flutter.dart'; import 'finamp_user_helper.dart'; import 'jellyfin_api_helper.dart'; import 'finamp_settings_helper.dart'; @@ -24,6 +26,8 @@ class QueueService { final _audioHandler = GetIt.instance(); final _finampUserHelper = GetIt.instance(); final _queueServiceLogger = Logger("QueueService"); + final _queuesBox = Hive.box("Queues"); + // internal state final List _queuePreviousTracks = @@ -62,6 +66,13 @@ class QueueService { late ShuffleOrder _shuffleOrder; int _queueAudioSourceIndex = 0; + // Flags for saving and loading saved queues + int _saveUpdateCycleCount = 0; + bool _saveUpdateImemdiate = false; + SavedQueueState _savedQueueState = SavedQueueState.preInit; + FinampStorableQueueInfo? _failedSavedQueue = null; + static const int _maxSavedQueues = 60; + QueueService() { // _queueServiceLogger.level = Level.OFF; @@ -88,9 +99,36 @@ class QueueService { _queueAudioSource.shuffleIndices.isNotEmpty) ? _queueAudioSource.shuffleIndices.indexOf(_queueAudioSourceIndex) : _queueAudioSourceIndex; + _queueServiceLogger.finer( "Play queue index changed, new index: $adjustedQueueIndex (actual index: $_queueAudioSourceIndex)"); _queueFromConcatenatingAudioSource(); + } else { + _saveUpdateImemdiate = true; + } + }); + + Stream.periodic(const Duration(seconds: 10)).listen((event) { + // Update once per minute in background, and up to once every ten seconds if + // pausing/seeking is occuring + // We also update on every track switch. + if (_saveUpdateCycleCount >= 5 || _saveUpdateImemdiate) { + if (_savedQueueState == SavedQueueState.pendingSave && + !_audioHandler.paused) { + _savedQueueState = SavedQueueState.saving; + } + if (_savedQueueState == SavedQueueState.saving) { + _saveUpdateImemdiate = false; + _saveUpdateCycleCount = 0; + FinampStorableQueueInfo info = FinampStorableQueueInfo.fromQueueInfo( + getQueue(), _audioHandler.playbackPosition.inMilliseconds); + if (info.songCount != 0) { + _queuesBox.put("latest", info); + _queueServiceLogger.finest("Saved new periodic queue $info"); + } + } + } else { + _saveUpdateCycleCount++; } }); @@ -186,8 +224,7 @@ class QueueService { return; } - final newQueueInfo = getQueue(); - _queueStream.add(newQueueInfo); + refreshQueueStream(); _currentTrackStream.add(_currentTrack); _audioHandler.mediaItem.add(_currentTrack?.item); _audioHandler.queue.add(_queuePreviousTracks @@ -196,6 +233,17 @@ class QueueService { .map((e) => e.item) .toList()); + if (_savedQueueState == SavedQueueState.saving) { + FinampStorableQueueInfo info = + FinampStorableQueueInfo.fromQueueInfo(getQueue(), null); + if (info.songCount != 0) { + _queuesBox.put("latest", info); + _queueServiceLogger.finest("Saved new rebuilt queue $info"); + } + _saveUpdateImemdiate = false; + _saveUpdateCycleCount = 0; + } + // only log queue if there's a change if (previousTrack?.id != _currentTrack?.id || previousTracksPreviousLength != _queuePreviousTracks.length || @@ -205,6 +253,139 @@ class QueueService { } } + Future performInitialQueueLoad() async { + if (_savedQueueState == SavedQueueState.preInit) { + try { + _savedQueueState = SavedQueueState.init; + var info = _queuesBox.get("latest"); + if (info != null) { + // push latest into queue history + if (info.songCount != 0) { + await _queuesBox.put(info.creation.toString(), info); + } + var keys = _queuesBox.values + .map((x) => DateTime.fromMillisecondsSinceEpoch(x.creation)) + .toList(); + keys.sort(); + _queueServiceLogger.finest("Stored queue dates: $keys"); + if (keys.length > _maxSavedQueues) { + var extra = keys + .getRange(0, keys.length - _maxSavedQueues) + .map((e) => e.millisecondsSinceEpoch.toString()); + _queueServiceLogger.finest("Deleting stored queues: $extra"); + _queuesBox.deleteAll(extra); + } + + if (FinampSettingsHelper.finampSettings.autoloadLastQueueOnStartup) { + await loadSavedQueue(info); + } else { + _savedQueueState = SavedQueueState.pendingSave; + } + } + } catch (e) { + _queueServiceLogger.severe(e); + rethrow; + } + } + } + + Future retryQueueLoad() async { + if (_savedQueueState == SavedQueueState.failed && + _failedSavedQueue != null) { + await loadSavedQueue(_failedSavedQueue!); + } + } + + Future loadSavedQueue(FinampStorableQueueInfo info) async { + if (_savedQueueState == SavedQueueState.loading) { + return Future.error("A saved queue is currently loading"); + } + // After loading queue, do not begin overwriting latest until the user modifies + // the queue or begins playback. This prevents saving unused queues that + // had loading errors or were immediately overwritten. + SavedQueueState? finalState = SavedQueueState.pendingSave; + try { + _savedQueueState = SavedQueueState.loading; + await stopPlayback(); + refreshQueueStream(); + + List allIds = info.previousTracks + + ((info.currentTrack == null) ? [] : [info.currentTrack!]) + + info.nextUp + + info.queue; + List uniqueIds = allIds.toSet().toList(); + Map idMap = {}; + + if (FinampSettingsHelper.finampSettings.isOffline) { + for (var id in uniqueIds) { + jellyfin_models.BaseItemDto? item = + _downloadsHelper.getDownloadedSong(id)?.song; + if (item != null) { + idMap[id] = item; + } + } + } else { + for (var slice in uniqueIds.slices(200)) { + List itemList = + await _jellyfinApiHelper.getItems(itemIds: slice) ?? []; + for (var d2 in itemList) { + idMap[d2.id] = d2; + } + } + } + + Map> items = { + "previous": + info.previousTracks.map((e) => idMap[e]).whereNotNull().toList(), + "current": + [info.currentTrack].map((e) => idMap[e]).whereNotNull().toList(), + "next": info.nextUp.map((e) => idMap[e]).whereNotNull().toList(), + "queue": info.queue.map((e) => idMap[e]).whereNotNull().toList(), + }; + sumLengths(int sum, Iterable val) => val.length + sum; + int loadedSongs = items.values.fold(0, sumLengths); + int droppedSongs = info.songCount - loadedSongs; + + if (_savedQueueState != SavedQueueState.loading) { + finalState = null; + return Future.error("Loading of saved Queue was interrupted."); + } + + await _replaceWholeQueue( + itemList: items["previous"]! + items["current"]! + items["queue"]!, + initialIndex: items["previous"]!.length, + beginPlaying: false, + source: info.source ?? + QueueItemSource( + type: QueueItemSourceType.unknown, + name: const QueueItemSourceName( + type: QueueItemSourceNameType.savedQueue), + id: "savedqueue")); + + Future seekFuture = Future.value(); + if ((info.currentTrackSeek ?? 0) > 5000) { + seekFuture = _audioHandler + .seek(Duration(milliseconds: (info.currentTrackSeek ?? 0) - 1000)); + } + + await addToNextUp(items: items["next"]!); + await seekFuture; + _queueServiceLogger.info("Loaded saved queue."); + if (loadedSongs == 0 && info.songCount > 0) { + finalState = SavedQueueState.failed; + _failedSavedQueue = info; + } else if (droppedSongs > 0) { + return Future.error( + "$droppedSongs songs in the Now Playing Queue could not be loaded."); + } + } finally { + if (finalState != null) { + _savedQueueState = finalState; + } + refreshQueueStream(); + } + } + Future startPlayback({ required List items, required QueueItemSource source, @@ -221,6 +402,14 @@ class QueueService { } } + if (_savedQueueState == SavedQueueState.saving) { + var info = _queuesBox.get("latest"); + if (info != null && info.songCount != 0) { + await _queuesBox.put(info.creation.toString(), info); + } + } + _savedQueueState = SavedQueueState.saving; + await _replaceWholeQueue( itemList: items, source: source, @@ -232,12 +421,12 @@ class QueueService { /// Replaces the queue with the given list of items. If startAtIndex is specified, Any items below it /// will be ignored. This is used for when the user taps in the middle of an album to start from that point. - Future _replaceWholeQueue({ - required List itemList, - required QueueItemSource source, - required int initialIndex, - FinampPlaybackOrder? order, - }) async { + Future _replaceWholeQueue( + {required List itemList, + required QueueItemSource source, + required int initialIndex, + FinampPlaybackOrder? order, + bool beginPlaying = true}) async { try { if (initialIndex > itemList.length) { return Future.error( @@ -252,6 +441,7 @@ class QueueService { List newItems = []; List newLinearOrder = []; List newShuffledOrder; + _queueServiceLogger.severe("I have $itemList"); for (int i = 0; i < itemList.length; i++) { jellyfin_models.BaseItemDto item = itemList[i]; try { @@ -309,7 +499,11 @@ class QueueService { // _queueStream.add(getQueue()); _queueFromConcatenatingAudioSource(); - await _audioHandler.play(); + if (beginPlaying) { + await _audioHandler.play(); + } else { + await _audioHandler.pause(); + } _audioHandler.nextInitialIndex = null; } catch (e) { @@ -335,6 +529,9 @@ class QueueService { QueueItemSource? source, }) async { try { + if (_savedQueueState == SavedQueueState.pendingSave) { + _savedQueueState = SavedQueueState.saving; + } List queueItems = []; for (final item in items) { queueItems.add(FinampQueueItem( @@ -364,6 +561,9 @@ class QueueService { QueueItemSource? source, }) async { try { + if (_savedQueueState == SavedQueueState.pendingSave) { + _savedQueueState = SavedQueueState.saving; + } List queueItems = []; for (final item in items) { queueItems.add(FinampQueueItem( @@ -379,10 +579,11 @@ class QueueService { } for (final queueItem in queueItems.reversed) { - await _queueAudioSource.insert(_queueAudioSourceIndex + 1, + int offset = min(_queueAudioSource.length, 1); + await _queueAudioSource.insert(_queueAudioSourceIndex + offset, await _queueItemToAudioSource(queueItem)); _queueServiceLogger.fine( - "Appended '${queueItem.item.title}' to Next Up (index ${_queueAudioSourceIndex + 1})"); + "Appended '${queueItem.item.title}' to Next Up (index ${_queueAudioSourceIndex + offset})"); } _queueFromConcatenatingAudioSource(); // update internal queues @@ -397,6 +598,9 @@ class QueueService { QueueItemSource? source, }) async { try { + if (_savedQueueState == SavedQueueState.pendingSave) { + _savedQueueState = SavedQueueState.saving; + } List queueItems = []; for (final item in items) { queueItems.add(FinampQueueItem( @@ -412,13 +616,13 @@ class QueueService { } _queueFromConcatenatingAudioSource(); // update internal queues - int offset = _queueNextUp.length; + int offset = _queueNextUp.length + min(_queueAudioSource.length, 1); for (final queueItem in queueItems) { - await _queueAudioSource.insert(_queueAudioSourceIndex + 1 + offset, + await _queueAudioSource.insert(_queueAudioSourceIndex + offset, await _queueItemToAudioSource(queueItem)); _queueServiceLogger.fine( - "Appended '${queueItem.item.title}' to Next Up (index ${_queueAudioSourceIndex + 1 + offset})"); + "Appended '${queueItem.item.title}' to Next Up (index ${_queueAudioSourceIndex + offset})"); offset++; } @@ -477,6 +681,7 @@ class QueueService { queue: _queue, nextUp: _queueNextUp, source: _order.originalSource, + saveState: _savedQueueState, ); } @@ -490,8 +695,13 @@ class QueueService { /// Returns the next [amount] QueueItems from Next Up and the regular queue. /// The length of the returned list may be less than [amount] if there are not enough items in the queue - List getNextXTracksInQueue(int amount) { + List getNextXTracksInQueue(int amount, {int reverse = 0}) { List nextTracks = []; + if (_queuePreviousTracks.isNotEmpty && reverse > 0) { + nextTracks.addAll(_queuePreviousTracks.sublist( + max(0, _queuePreviousTracks.length - reverse), + _queuePreviousTracks.length)); + } if (_queueNextUp.isNotEmpty) { nextTracks .addAll(_queueNextUp.sublist(0, min(amount, _queueNextUp.length))); diff --git a/pubspec.lock b/pubspec.lock index 5694503ec..32627e1dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "67d591d602906ef9201caf93452495ad1812bea2074f04e25dbd7c133785821b" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.7" build_runner_core: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: built_value - sha256: "723b4021e903217dfc445ec4cf5b42e27975aece1fc4ebbc1ca6329c2d9fb54e" + sha256: "69acb7007eb2a31dc901512bfe0f7b767168be34cb734835d54c070bfa74c1b2" url: "https://pub.dev" source: hosted - version: "8.7.0" + version: "8.8.0" characters: dependency: transitive description: @@ -226,7 +226,7 @@ packages: source: hosted version: "4.8.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a @@ -245,10 +245,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "445db18de832dba8d851e287aff8ccf169bed30d2e94243cb54c7d2f1ed2142c" + sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e url: "https://pub.dev" source: hosted - version: "0.3.3+6" + version: "0.3.3+8" crypto: dependency: transitive description: @@ -400,10 +400,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: d261b0f2461e0595b96f92ed807841eb72cea84a6b12b8fd0c76e5ed803e7921 + sha256: da9591d1f8d5881628ccd5c25c40e74fc3eef50ba45e40c3905a06e1712412d5 url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.9" flutter_sticky_header: dependency: "direct main" description: @@ -805,10 +805,10 @@ packages: dependency: transitive description: name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.2" platform: dependency: transitive description: @@ -869,10 +869,10 @@ packages: dependency: transitive description: name: riverpod - sha256: "08451ddbaad6eae73e2422d8109775885623340d721c6637b8719c9f4b478848" + sha256: "942999ee48b899f8a46a860f1e13cee36f2f77609eb54c5b7a669bb20d550b11" url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.9" rxdart: dependency: "direct main" description: @@ -1074,10 +1074,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2" + sha256: "7286aec002c8feecc338cc33269e96b73955ab227456e9fb2a91f7fab8a358e9" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.2" url_launcher_windows: dependency: transitive description: @@ -1154,10 +1154,10 @@ packages: dependency: transitive description: name: xml - sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.5.0" yaml: dependency: transitive description: @@ -1167,5 +1167,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" - flutter: ">=3.13.0" + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index 58a5addcc..7c543b2b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: hive_flutter: ^1.1.0 file_sizes: ^1.0.6 logging: ^1.1.1 + collection: ^1.18.0 clipboard: ^0.1.3 file_picker: ^5.2.8 permission_handler: ^10.2.0