From 36871c10f93e9dd1ad13c46fd2b73469b912244a Mon Sep 17 00:00:00 2001 From: Komodo <45665554+Komodo5197@users.noreply.github.com> Date: Wed, 6 Dec 2023 02:00:08 -0500 Subject: [PATCH] Improved loading indicater and Now Playing bar transitions. Added setting to diable queue autoload. --- .../loadQueueOnStartup_selector.dart | 29 + lib/components/PlayerScreen/queue_list.dart | 2 +- lib/components/PlayerScreen/song_info.dart | 55 +- .../queue_restore_tile.dart | 9 +- lib/components/album_image.dart | 47 +- lib/components/now_playing_bar.dart | 819 +++++++++--------- lib/l10n/app_en.arb | 8 + lib/models/finamp_models.dart | 4 + lib/models/finamp_models.g.dart | 8 +- .../audio_service_settings_screen.dart | 2 + lib/screens/player_screen.dart | 3 +- lib/screens/queue_restore_screen.dart | 2 +- lib/services/finamp_settings_helper.dart | 10 +- .../player_screen_theme_provider.dart | 38 +- lib/services/queue_service.dart | 59 +- 15 files changed, 584 insertions(+), 511 deletions(-) create mode 100644 lib/components/AudioServiceSettingsScreen/loadQueueOnStartup_selector.dart rename lib/components/{ => QueueRestoreScreen}/queue_restore_tile.dart (93%) diff --git a/lib/components/AudioServiceSettingsScreen/loadQueueOnStartup_selector.dart b/lib/components/AudioServiceSettingsScreen/loadQueueOnStartup_selector.dart new file mode 100644 index 000000000..173349dac --- /dev/null +++ b/lib/components/AudioServiceSettingsScreen/loadQueueOnStartup_selector.dart @@ -0,0 +1,29 @@ +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/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart index a359b85e8..e253069dd 100644 --- a/lib/components/PlayerScreen/queue_list.dart +++ b/lib/components/PlayerScreen/queue_list.dart @@ -274,7 +274,7 @@ 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(context)).value; return AnimatedTheme( duration: const Duration(milliseconds: 500), diff --git a/lib/components/PlayerScreen/song_info.dart b/lib/components/PlayerScreen/song_info.dart index 964bf98b4..25e43ae0e 100644 --- a/lib/components/PlayerScreen/song_info.dart +++ b/lib/components/PlayerScreen/song_info.dart @@ -8,19 +8,14 @@ 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 +113,7 @@ class _SongInfoState extends State { } } -class _PlayerScreenAlbumImage extends ConsumerWidget { +class _PlayerScreenAlbumImage extends StatelessWidget { _PlayerScreenAlbumImage({ Key? key, required this.queueItem, @@ -127,7 +122,7 @@ class _PlayerScreenAlbumImage extends ConsumerWidget { final FinampQueueItem queueItem; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final queueService = GetIt.instance(); final item = queueItem.item.extras?["itemJson"] != null @@ -166,51 +161,7 @@ class _PlayerScreenAlbumImage extends ConsumerWidget { : 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, - ); - } - }), + updateProvider: true, ), ), ); diff --git a/lib/components/queue_restore_tile.dart b/lib/components/QueueRestoreScreen/queue_restore_tile.dart similarity index 93% rename from lib/components/queue_restore_tile.dart rename to lib/components/QueueRestoreScreen/queue_restore_tile.dart index 5344bdd2f..a1d2d266b 100644 --- a/lib/components/queue_restore_tile.dart +++ b/lib/components/QueueRestoreScreen/queue_restore_tile.dart @@ -3,12 +3,11 @@ 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 'package:intl/intl.dart'; -import '../models/finamp_models.dart'; -import '../services/queue_service.dart'; -import 'album_image.dart'; -import 'error_snackbar.dart'; +import '../../models/finamp_models.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); diff --git a/lib/components/album_image.dart b/lib/components/album_image.dart index e84d8c117..00e55cc7d 100644 --- a/lib/components/album_image.dart +++ b/lib/components/album_image.dart @@ -1,5 +1,6 @@ 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,11 +12,11 @@ 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.updateProvider = false, this.itemsToPrecache, this.borderRadius, }) : super(key: key); @@ -24,7 +25,7 @@ class AlbumImage extends StatelessWidget { final BaseItemDto? item; /// A callback to get the image provider once it has been fetched. - final ImageProviderCallback? imageProviderCallback; + final bool updateProvider; /// A list of items to precache final List? itemsToPrecache; @@ -34,12 +35,12 @@ class AlbumImage extends StatelessWidget { 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); + if (updateProvider) { + _BareAlbumImageState.registerThemeUpdate(null, ref); } return ClipRRect( @@ -71,7 +72,7 @@ class AlbumImage extends StatelessWidget { item: item!, maxWidth: physicalWidth, maxHeight: physicalHeight, - imageProviderCallback: imageProviderCallback, + updateProvider: updateProvider, itemsToPrecache: itemsToPrecache, ); }), @@ -81,7 +82,7 @@ class AlbumImage extends StatelessWidget { } /// An [AlbumImage] without any of the padding or media size detection. -class BareAlbumImage extends StatefulWidget { +class BareAlbumImage extends ConsumerStatefulWidget { const BareAlbumImage({ Key? key, required this.item, @@ -89,8 +90,8 @@ class BareAlbumImage extends StatefulWidget { this.maxHeight, this.errorBuilder, this.placeholderBuilder, - this.imageProviderCallback, this.itemsToPrecache, + this.updateProvider = false, }) : super(key: key); final BaseItemDto item; @@ -98,16 +99,16 @@ class BareAlbumImage extends StatefulWidget { final int? maxHeight; final WidgetBuilder? placeholderBuilder; final OctoErrorBuilder? errorBuilder; - final ImageProviderCallback? imageProviderCallback; + final bool updateProvider; /// A list of items to precache final List? itemsToPrecache; @override - State createState() => _BareAlbumImageState(); + ConsumerState createState() => _BareAlbumImageState(); } -class _BareAlbumImageState extends State { +class _BareAlbumImageState extends ConsumerState{ late Future _albumImageContentFuture; late WidgetBuilder _placeholderBuilder; late OctoErrorBuilder _errorBuilder; @@ -155,14 +156,24 @@ class _BareAlbumImageState extends State { (context, _, __) => const _AlbumImageErrorPlaceholder(); } + static void registerThemeUpdate (ImageProvider? imageProvider, WidgetRef ref) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + // Do not update provider if values are == to prevent redraw loops. + if (ref.read(currentAlbumImageProvider.notifier).state == imageProvider) { + return; + } + ref.read(currentAlbumImageProvider.notifier).state = imageProvider; + }); + } + @override Widget build(BuildContext context) { return FutureBuilder( future: _albumImageContentFuture, builder: (context, snapshot) { if (snapshot.hasData) { - if (widget.imageProviderCallback != null) { - widget.imageProviderCallback!(snapshot.data!); + if (widget.updateProvider) { + _BareAlbumImageState.registerThemeUpdate(snapshot.data!, ref); } return OctoImage( @@ -174,14 +185,14 @@ class _BareAlbumImageState extends State { } if (snapshot.hasError) { - if (widget.imageProviderCallback != null) { - widget.imageProviderCallback!(null); + if (widget.updateProvider) { + _BareAlbumImageState.registerThemeUpdate(null, ref); } return const _AlbumImageErrorPlaceholder(); } - if (widget.imageProviderCallback != null) { - widget.imageProviderCallback!(null); + if (widget.updateProvider) { + _BareAlbumImageState.registerThemeUpdate(null, ref); } return Builder(builder: _placeholderBuilder); diff --git a/lib/components/now_playing_bar.dart b/lib/components/now_playing_bar.dart index 8ea0be30e..dd02ec6d3 100644 --- a/lib/components/now_playing_bar.dart +++ b/lib/components/now_playing_bar.dart @@ -5,16 +5,13 @@ 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'; @@ -26,7 +23,6 @@ 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({ @@ -38,10 +34,6 @@ class NowPlayingBar extends ConsumerWidget { const horizontalPadding = 8.0; const albumImageSize = 70.0; - // TODO get these colors working well - // maybe move streambuilder so we aren't in theme context? - // TODO disable ability to click now playing by moving out of gesture widget - return Padding( padding: const EdgeInsets.only(left: 12.0, bottom: 12.0, right: 12.0), child: Material( @@ -77,20 +69,21 @@ class NowPlayingBar extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( - width: albumImageSize, - height: albumImageSize, - decoration: const ShapeDecoration( - shape: Border(), - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - // TODO add some sort of loading spinner here - child: Icon(TablerIcons.player_pause)), + width: albumImageSize, + height: albumImageSize, + decoration: const ShapeDecoration( + shape: Border(), + color: Color.fromRGBO(0, 0, 0, 0.3), + ), + child: Center(child: CircularProgressIndicator.adaptive()), + ), Expanded( child: Container( height: albumImageSize, padding: const EdgeInsets.only(left: 12, right: 4), alignment: Alignment.centerLeft, - child: Text(AppLocalizations.of(context)!.queueLoadingMessage)), + child: Text( + AppLocalizations.of(context)!.queueLoadingMessage)), ), ], ), @@ -103,7 +96,30 @@ class NowPlayingBar extends ConsumerWidget { @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); + var imageTheme = ref.watch(playerScreenThemeProvider(context)).value; + + // Default theme for inital loading of saved queue + if (imageTheme == null) { + final theme = Theme.of(context); + final lighter = theme.brightness == Brightness.dark; + Color accent = lighter + ? Color.fromARGB(255, 50, 50, 50) + : Color.fromARGB(255, 200, 200, 200); + + final background = Color.alphaBlend( + lighter + ? Colors.black.withOpacity(0.675) + : Colors.white.withOpacity(0.675), + accent); + + accent = accent.atContrast(4.5, background, lighter); + + imageTheme = ColorScheme.fromSwatch( + primarySwatch: generateMaterialColor(accent), + accentColor: accent, + brightness: theme.brightness, + ); + } const elevation = 16.0; const horizontalPadding = 8.0; @@ -119,35 +135,36 @@ class NowPlayingBar extends ConsumerWidget { 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: 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!.saveState == SavedQueueState.loading) { - return loadingQueueBar(context, ref); - } else 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( + child: StreamBuilder( + stream: queueService.getQueueStream(), + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.data!.saveState == SavedQueueState.loading) { + return loadingQueueBar(context, ref); + } else 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 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( @@ -176,377 +193,367 @@ class NowPlayingBar extends ConsumerWidget { return false; }, child: StreamBuilder( - stream: mediaStateStream, + stream: mediaStateStream + .where((event) => event.mediaItem != null), builder: (context, snapshot) { + final MediaState mediaState; 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)), - ), + mediaState = snapshot.data!; + } else { + mediaState = MediaState( + audioHandler.mediaItem.value, + audioHandler.playbackState.value); + } + // 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, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + AlbumImage( + updateProvider: true, + 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: [ - 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(), + 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(); + } + }), ), - 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, + 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), ), - 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), + 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), ), ), - ), - ); - } 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, + 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, ); - 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( + '/', + 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, - ), + ), + 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(); - }, - ), + ), + 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, @@ -558,15 +565,15 @@ class NowPlayingBar extends ConsumerWidget { ), ), ), - ); - } 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 4b68d5b76..3a1a1defd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -642,5 +642,13 @@ "queueLoadingMessage": "Loading saved Queue...", "@queueLoadingMessage": { "description": "Message displayed on now-playing bar when a saved queue is loading." + }, + "autoloadLastQueueOnStartup": "Auto-restore Last Queue", + "@autoloadLastQueueOnStartup": { + "description": "Setting to restore last queue on startup" + }, + "autoloadLastQueueOnStartupSubtitle": "Upon app startup, attempt to reload the Now-Playing queue from the previous session.", + "@autoloadLastQueueOnStartupSubtitle": { + "description": "Description of the setting to restore last queue on startup" } } diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index 2054ad0ca..056643fea 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -87,6 +87,7 @@ class FinampSettings { required this.tabSortBy, required this.tabSortOrder, this.loopMode = _defaultLoopMode, + this.autoloadLastQueueOnStartup = true, }); @HiveField(0) @@ -173,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( diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index d48ccfd7f..5545096e1 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 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/player_screen.dart b/lib/screens/player_screen.dart index ae5e1e06c..f0a670cf1 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -11,7 +11,6 @@ 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'; @@ -29,7 +28,7 @@ class PlayerScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final imageTheme = ref.watch(playerScreenThemeProvider); + final imageTheme = ref.watch(playerScreenThemeProvider(context)).value; return AnimatedTheme( duration: const Duration(milliseconds: 500), diff --git a/lib/screens/queue_restore_screen.dart b/lib/screens/queue_restore_screen.dart index 9cf34aa2f..151015068 100644 --- a/lib/screens/queue_restore_screen.dart +++ b/lib/screens/queue_restore_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:hive/hive.dart'; -import '../components/queue_restore_tile.dart'; +import '../components/QueueRestoreScreen/queue_restore_tile.dart'; import '../models/finamp_models.dart'; class QueueRestoreScreen extends StatelessWidget { diff --git a/lib/services/finamp_settings_helper.dart b/lib/services/finamp_settings_helper.dart index b403b4985..83a976f69 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,15 @@ 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/player_screen_theme_provider.dart b/lib/services/player_screen_theme_provider.dart index fccecd9d3..6eea4d3a4 100644 --- a/lib/services/player_screen_theme_provider.dart +++ b/lib/services/player_screen_theme_provider.dart @@ -1,5 +1,41 @@ +import 'package:finamp/at_contrast.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:palette_generator/palette_generator.dart'; + +import '../generate_material_color.dart'; +import 'current_album_image_provider.dart'; final playerScreenThemeProvider = - StateProvider.autoDispose((_) => null); + FutureProvider.autoDispose.family((ref,context) async { + ImageProvider? image = ref.watch(currentAlbumImageProvider); + + if (image != null) { + final theme = Theme.of(context); + + final palette = + await PaletteGenerator.fromImageProvider( + image, + 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); + + return ColorScheme.fromSwatch( + primarySwatch: generateMaterialColor(accent), + accentColor: accent, + brightness: theme.brightness, + ); + } + }); diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 072a4f4d8..17b552ecb 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -71,6 +71,8 @@ class QueueService { int _saveUpdateCycleCount = 0; bool _saveUpdateImemdiate = false; SavedQueueState _savedQueueState = SavedQueueState.preInit; + static const int _maxSavedQueues = 60; + static const int _savedQueueWorkers = 10; QueueService() { // _queueServiceLogger.level = Level.OFF; @@ -253,14 +255,14 @@ class QueueService { DateTime.fromMillisecondsSinceEpoch(x.creation)).toList(); keys.sort(); _queueServiceLogger.finest("Stored queue dates: $keys"); - if (keys.length > 30) { - var extra = keys.getRange(0, keys.length - 30).map((e) => + 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 (true){ // TODO add user setting for autoload, check here + if (FinampSettingsHelper.finampSettings.autoloadLastQueueOnStartup){ await loadSavedQueue(info); } else{ _savedQueueState = SavedQueueState.saving; @@ -290,25 +292,38 @@ class QueueService { try { _savedQueueState = SavedQueueState.loading; await stopPlayback(); + refreshQueueStream(); - // TODO find some way to throttle requests, interrupt loading earlier - Future seekFuture = Future.value(null); - Map>> futures = { - "previous": info.previousTracks.map(getTrackFromId), - "current": (info.currentTrack == null) ? [] : [ - getTrackFromId(info.currentTrack!) - ], - "next": info.nextUp.map(getTrackFromId), - "queue": info.queue.map(getTrackFromId), - }; - Map>items = {}; - for (MapEntry entry in futures.entries) { - items[entry.key] = - [for (var i in await Future.wait(entry.value)) if ( i != null ) i]; + List allIds = info.previousTracks + ( (info.currentTrack == null) ? []:[info.currentTrack!] ) + info.nextUp + info.queue; + Iterator idIterator = allIds.iterator; + List> idFutures = []; + Map idMap = {}; + + Future worker() async { + while(idIterator.moveNext() && _savedQueueState == SavedQueueState.loading){ + String id = idIterator.current; + if ( ! idMap.containsKey(id)){ + jellyfin_models.BaseItemDto? item = await getTrackFromId(id); + if(item != null){ + idMap[id]=item; + } + } + } } + // Limit parallel item lookups to 10 to reduce interface lag and server load + for ( int i=0; i<_savedQueueWorkers; i++ ){ + idFutures.add(worker()); + } + await Future.wait(idFutures); + + Map> items = { + "previous": [for (var i in info.previousTracks) if ( idMap.containsKey(i) ) idMap[i]!], + "current": (idMap.containsKey(info.currentTrack))?[idMap[info.currentTrack]!]:[], + "next": [for (var i in info.nextUp) if ( idMap.containsKey(i) ) idMap[i]!], + "queue": [for (var i in info.queue) if ( idMap.containsKey(i) ) idMap[i]!], + }; sumLengths(int sum, Iterable val) => val.length + sum; - int droppedSongs = futures.values.fold(0, sumLengths) - - items.values.fold(0, sumLengths); + int droppedSongs = allIds.length - items.values.fold(0, sumLengths); if (_savedQueueState != SavedQueueState.loading) { return Future.error("Loading of saved Queue was interrupted."); @@ -326,6 +341,7 @@ class QueueService { ) ); + Future seekFuture = Future.value(); if ((info.currentTrackSeek ?? 0) > 5000) { seekFuture = _audioHandler.seek( Duration(milliseconds: (info.currentTrackSeek ?? 0) - 1000)); @@ -339,8 +355,7 @@ class QueueService { "$droppedSongs songs in the Now Playing Queue could not be loaded."); } } finally { - // TODO revert testing - //_savedQueueState = SavedQueueState.saving; + _savedQueueState = SavedQueueState.saving; refreshQueueStream(); } } @@ -374,7 +389,7 @@ 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({ // TODO archive old queue here? Increase saved queue limit? + Future _replaceWholeQueue({ required List itemList, required QueueItemSource source, required int initialIndex,