diff --git a/.flutter b/.flutter deleted file mode 160000 index db7ef5bf9..000000000 --- a/.flutter +++ /dev/null @@ -1 +0,0 @@ -Subproject commit db7ef5bf9f59442b0e200a90587e8fa5e0c6336a diff --git a/android/build.gradle b/android/build.gradle index 713d7f6e6..23eb4f6ed 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '1.8.20' repositories { google() mavenCentral() diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 086f153b5..2234b552f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -143,7 +143,7 @@ SPEC CHECKSUMS: audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_session: 4f3e461722055d21515cf3261b64c973c062f345 CropViewController: 58fb440f30dac788b129d2a1f24cffdcb102669c - device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed + device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea DKCamera: a902b66921fca14b7a75266feb8c7568aa7caa71 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 diff --git a/lib/components/AlbumScreen/album_screen_content.dart b/lib/components/AlbumScreen/album_screen_content.dart index ac647a8d2..de34e36d7 100644 --- a/lib/components/AlbumScreen/album_screen_content.dart +++ b/lib/components/AlbumScreen/album_screen_content.dart @@ -102,7 +102,7 @@ class _AlbumScreenContentState extends State { childrenForList: childrenOfThisDisc, childrenForQueue: widget.children, parent: widget.parent, - onDelete: onDelete, + onRemoveFromList: onDelete, ), ) else if (widget.children.isNotEmpty) @@ -110,7 +110,7 @@ class _AlbumScreenContentState extends State { childrenForList: widget.children, childrenForQueue: widget.children, parent: widget.parent, - onDelete: onDelete, + onRemoveFromList: onDelete, ), ], ), @@ -124,14 +124,14 @@ class SongsSliverList extends StatefulWidget { required this.childrenForList, required this.childrenForQueue, required this.parent, + this.onRemoveFromList, this.showPlayCount = false, - this.onDelete, }) : super(key: key); final List childrenForList; final List childrenForQueue; final BaseItemDto parent; - final BaseItemDtoCallback? onDelete; + final BaseItemDtoCallback? onRemoveFromList; final bool showPlayCount; @override @@ -176,10 +176,10 @@ class _SongsSliverListState extends State { index: index + indexOffset, parentId: widget.parent.id, parentName: widget.parent.name, - onDelete: () { + onRemoveFromList: () { final item = removeItem(); - if (widget.onDelete != null) { - widget.onDelete!(item); + if (widget.onRemoveFromList != null) { + widget.onRemoveFromList!(item); } }, isInPlaylist: widget.parent.type == "Playlist", diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index 056ac63b7..7d5e634fb 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -1,20 +1,27 @@ import 'package:audio_service/audio_service.dart'; -import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/components/AlbumScreen/song_menu.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_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 '../../models/jellyfin_models.dart'; +import 'package:finamp/models/jellyfin_models.dart' as jellyfin_models; +import 'package:finamp/models/finamp_models.dart'; import '../../services/audio_service_helper.dart'; +import '../../services/current_album_image_provider.dart'; import '../../services/jellyfin_api_helper.dart'; import '../../services/finamp_settings_helper.dart'; import '../../services/downloads_helper.dart'; +import '../../services/player_screen_theme_provider.dart'; import '../../services/process_artist.dart'; import '../../services/music_player_background_task.dart'; import '../../screens/album_screen.dart'; import '../../screens/add_to_playlist_screen.dart'; +import '../PlayerScreen/album_chip.dart'; +import '../PlayerScreen/artist_chip.dart'; import '../favourite_button.dart'; import '../album_image.dart'; import '../print_duration.dart'; @@ -51,68 +58,41 @@ class SongListTile extends StatefulWidget { this.parentName, this.isSong = false, this.showArtists = true, + this.onRemoveFromList, this.showPlayCount = false, - this.onDelete, /// Whether this widget is being displayed in a playlist. If true, will show /// the remove from playlist button. this.isInPlaylist = false, }) : super(key: key); - final BaseItemDto item; - final List? children; + final jellyfin_models.BaseItemDto item; + final List? children; final int? index; final bool isSong; final String? parentId; final String? parentName; final bool showArtists; + final VoidCallback? onRemoveFromList; final bool showPlayCount; - final VoidCallback? onDelete; final bool isInPlaylist; @override State createState() => _SongListTileState(); } -class _SongListTileState extends State { +class _SongListTileState extends State + with SingleTickerProviderStateMixin { final _audioServiceHelper = GetIt.instance(); final _queueService = GetIt.instance(); final _audioHandler = GetIt.instance(); final _jellyfinApiHelper = GetIt.instance(); + bool songMenuFullSize = false; @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; - /// Sets the item's favourite on the Jellyfin server. - Future setFavourite() async { - try { - // We switch the widget state before actually doing the request to - // make the app feel faster (without, there is a delay from the - // user adding the favourite and the icon showing) - setState(() { - widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite; - }); - - // Since we flipped the favourite state already, we can use the flipped - // state to decide which API call to make - final newUserData = widget.item.userData!.isFavorite - ? await _jellyfinApiHelper.addFavourite(widget.item.id) - : await _jellyfinApiHelper.removeFavourite(widget.item.id); - - if (!mounted) return; - - setState(() { - widget.item.userData = newUserData; - }); - } catch (e) { - setState(() { - widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite; - }); - errorSnackbar(e, context); - } - } - final listTile = ListTile( leading: AlbumImage(item: widget.item), title: StreamBuilder( @@ -234,220 +214,8 @@ class _SongListTileState extends State { // offline mode, we need the album to actually be downloaded to show // its metadata. This function also checks if widget.item.parentId is // null. - final canGoToAlbum = widget.item.albumId != widget.parentId && - _isAlbumDownloadedIfOffline(widget.item.parentId); - - // Some options are disabled in offline mode - final isOffline = FinampSettingsHelper.finampSettings.isOffline; - - final selection = await showMenu( - context: context, - position: RelativeRect.fromLTRB( - details.globalPosition.dx, - details.globalPosition.dy, - screenSize.width - details.globalPosition.dx, - screenSize.height - details.globalPosition.dy, - ), - items: [ - PopupMenuItem( - value: SongListTileMenuItems.addToQueue, - child: ListTile( - leading: const Icon(Icons.queue_music), - title: Text(AppLocalizations.of(context)!.addToQueue), - ), - ), - if (_queueService.getQueue().nextUp.isNotEmpty) - PopupMenuItem( - value: SongListTileMenuItems.playNext, - child: ListTile( - leading: const Icon(TablerIcons.hourglass_low), - title: Text(AppLocalizations.of(context)!.playNext), - ), - ), - PopupMenuItem( - value: SongListTileMenuItems.addToNextUp, - child: ListTile( - leading: const Icon(TablerIcons.hourglass_high), - title: Text(AppLocalizations.of(context)!.addToNextUp), - ), - ), - widget.isInPlaylist - ? PopupMenuItem( - enabled: !isOffline, - value: SongListTileMenuItems.removeFromPlaylist, - child: ListTile( - leading: const Icon(Icons.playlist_remove), - title: Text(AppLocalizations.of(context)! - .removeFromPlaylistTitle), - enabled: !isOffline && widget.parentId != null, - ), - ) - : PopupMenuItem( - enabled: !isOffline, - value: SongListTileMenuItems.addToPlaylist, - child: ListTile( - leading: const Icon(Icons.playlist_add), - title: Text( - AppLocalizations.of(context)!.addToPlaylistTitle), - enabled: !isOffline, - ), - ), - PopupMenuItem( - enabled: !isOffline, - value: SongListTileMenuItems.instantMix, - child: ListTile( - leading: const Icon(Icons.explore), - title: Text(AppLocalizations.of(context)!.instantMix), - enabled: !isOffline, - ), - ), - PopupMenuItem( - enabled: canGoToAlbum, - value: SongListTileMenuItems.goToAlbum, - child: ListTile( - leading: const Icon(Icons.album), - title: Text(AppLocalizations.of(context)!.goToAlbum), - enabled: canGoToAlbum, - ), - ), - widget.item.userData!.isFavorite - ? PopupMenuItem( - value: SongListTileMenuItems.removeFavourite, - child: ListTile( - leading: const Icon(Icons.favorite_border), - title: - Text(AppLocalizations.of(context)!.removeFavourite), - ), - ) - : PopupMenuItem( - value: SongListTileMenuItems.addFavourite, - child: ListTile( - leading: const Icon(Icons.favorite), - title: Text(AppLocalizations.of(context)!.addFavourite), - ), - ), - ], - ); - - if (!mounted) return; - switch (selection) { - case SongListTileMenuItems.addToQueue: - await _queueService.addToQueue( - items: [widget.item], - source: QueueItemSource( - type: QueueItemSourceType.unknown, - name: QueueItemSourceName( - type: QueueItemSourceNameType.preTranslated, - pretranslatedName: AppLocalizations.of(context)!.queue), - id: widget.parentId ?? "unknown")); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.addedToQueue), - )); - break; - - case SongListTileMenuItems.playNext: - await _queueService.addNext(items: [widget.item]); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(AppLocalizations.of(context)!.confirmPlayNext("track")), - )); - break; - - case SongListTileMenuItems.addToNextUp: - await _queueService.addToNextUp(items: [widget.item]); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - AppLocalizations.of(context)!.confirmAddToNextUp("track")), - )); - break; - - case SongListTileMenuItems.addToPlaylist: - Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName, - arguments: widget.item.id); - break; - - case SongListTileMenuItems.removeFromPlaylist: - try { - await _jellyfinApiHelper.removeItemsFromPlaylist( - playlistId: widget.parentId!, - entryIds: [widget.item.playlistItemId!]); - - if (!mounted) return; - - await _jellyfinApiHelper.getItems( - parentItem: - await _jellyfinApiHelper.getItemById(widget.item.parentId!), - sortBy: "ParentIndexNumber,IndexNumber,SortName", - includeItemTypes: "Audio", - isGenres: false, - ); - - if (!mounted) return; - - if (widget.onDelete != null) widget.onDelete!(); - - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(AppLocalizations.of(context)!.removedFromPlaylist), - )); - } catch (e) { - errorSnackbar(e, context); - } - break; - - case SongListTileMenuItems.instantMix: - await _audioServiceHelper.startInstantMixForItem(widget.item); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.startingInstantMix), - )); - break; - case SongListTileMenuItems.goToAlbum: - late BaseItemDto album; - if (FinampSettingsHelper.finampSettings.isOffline) { - // If offline, load the album's BaseItemDto from DownloadHelper. - final downloadsHelper = GetIt.instance(); - - // downloadedParent won't be null here since the menu item already - // checks if the DownloadedParent exists. - album = downloadsHelper - .getDownloadedParent(widget.item.parentId!)! - .item; - } else { - // If online, get the album's BaseItemDto from the server. - try { - album = - await _jellyfinApiHelper.getItemById(widget.item.parentId!); - } catch (e) { - errorSnackbar(e, context); - break; - } - } - - if (!mounted) return; - - Navigator.of(context) - .pushNamed(AlbumScreen.routeName, arguments: album); - break; - case SongListTileMenuItems.addFavourite: - case SongListTileMenuItems.removeFavourite: - await setFavourite(); - break; - case null: - break; - } + showModalSongMenu(context: context, item: widget.item, isInPlaylist: widget.isInPlaylist, onRemoveFromList: widget.onRemoveFromList, parentId: widget.parentId); }, child: widget.isSong ? listTile @@ -505,7 +273,7 @@ class _SongListTileState extends State { /// If offline, check if an album is downloaded. Always returns true if online. /// Returns false if albumId is null. -bool _isAlbumDownloadedIfOffline(String? albumId) { +bool isAlbumDownloadedIfOffline(String? albumId) { if (albumId == null) { return false; } else if (FinampSettingsHelper.finampSettings.isOffline) { diff --git a/lib/components/AlbumScreen/song_menu.dart b/lib/components/AlbumScreen/song_menu.dart new file mode 100644 index 000000000..74d87a0b1 --- /dev/null +++ b/lib/components/AlbumScreen/song_menu.dart @@ -0,0 +1,900 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:finamp/at_contrast.dart'; +import 'package:finamp/components/PlayerScreen/queue_list.dart'; +import 'package:finamp/components/PlayerScreen/sleep_timer_cancel_dialog.dart'; +import 'package:finamp/components/PlayerScreen/sleep_timer_dialog.dart'; +import 'package:finamp/generate_material_color.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/screens/artist_screen.dart'; +import 'package:finamp/screens/blurred_player_screen_background.dart'; +import 'package:finamp/services/album_image_provider.dart'; +import 'package:finamp/services/music_player_background_task.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; +import 'package:get_it/get_it.dart'; +import 'package:octo_image/octo_image.dart'; +import 'package:provider/provider.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:palette_generator/palette_generator.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../screens/add_to_playlist_screen.dart'; +import '../../screens/album_screen.dart'; +import '../../services/audio_service_helper.dart'; +import '../../services/current_album_image_provider.dart'; +import '../../services/downloads_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/player_screen_theme_provider.dart'; +import '../PlayerScreen/album_chip.dart'; +import '../PlayerScreen/artist_chip.dart'; +import '../PlayerScreen/song_info.dart'; +import '../album_image.dart'; +import '../error_snackbar.dart'; +import 'song_list_tile.dart'; + +Future showModalSongMenu({ + required BuildContext context, + required BaseItemDto item, + required String? parentId, + bool showPlaybackControls = false, + bool isInPlaylist = false, + Function? onRemoveFromList, +}) async { + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + final canGoToAlbum = isAlbumDownloadedIfOffline(item.parentId); + final canGoToArtist = !isOffline; + final canGoToGenre = !isOffline; + + Vibrate.feedback(FeedbackType.impact); + + await showModalBottomSheet( + context: context, + isDismissible: true, + enableDrag: true, + isScrollControlled: true, + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + backgroundColor: (Theme.of(context).brightness == Brightness.light + ? Colors.white + : Colors.black) + .withOpacity(0.9), + useSafeArea: true, + builder: (BuildContext context) { + return SongMenu( + item: item, + isOffline: isOffline, + showPlaybackControls: showPlaybackControls, + isInPlaylist: isInPlaylist, + canGoToAlbum: canGoToAlbum, + canGoToArtist: canGoToArtist, + canGoToGenre: canGoToGenre, + onRemoveFromList: onRemoveFromList, + parentId: parentId); + }); +} + +class SongMenu extends StatefulWidget { + const SongMenu({ + super.key, + required this.item, + required this.isOffline, + required this.showPlaybackControls, + required this.isInPlaylist, + required this.canGoToAlbum, + required this.canGoToArtist, + required this.canGoToGenre, + required this.onRemoveFromList, + required this.parentId, + }); + + final BaseItemDto item; + final bool isOffline; + final bool showPlaybackControls; + final bool isInPlaylist; + final bool canGoToAlbum; + final bool canGoToArtist; + final bool canGoToGenre; + final Function? onRemoveFromList; + final String? parentId; + + @override + State createState() => _SongMenuState(); +} + +bool isBaseItemInQueueItem(BaseItemDto baseItem, FinampQueueItem? queueItem) { + + if (queueItem != null) { + final baseItem = + BaseItemDto.fromJson(queueItem.item.extras!["itemJson"]); + return baseItem.id == queueItem.id; + } + return false; +} + +class _SongMenuState extends State { + final _jellyfinApiHelper = GetIt.instance(); + final _audioServiceHelper = GetIt.instance(); + final _audioHandler = GetIt.instance(); + final _queueService = GetIt.instance(); + + ColorScheme? _imageTheme; + ImageProvider? _imageProvider; + + /// Sets the item's favourite on the Jellyfin server. + Future toggleFavorite() async { + try { + final currentTrack =_queueService.getCurrentTrack(); + if (isBaseItemInQueueItem(widget.item, currentTrack)) { + setFavourite(currentTrack!, context); + Vibrate.feedback(FeedbackType.success); + return; + } + + // We switch the widget state before actually doing the request to + // make the app feel faster (without, there is a delay from the + // user adding the favourite and the icon showing) + setState(() { + widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite; + }); + Vibrate.feedback(FeedbackType.success); + + // Since we flipped the favourite state already, we can use the flipped + // state to decide which API call to make + final newUserData = widget.item.userData!.isFavorite + ? await _jellyfinApiHelper.addFavourite(widget.item.id) + : await _jellyfinApiHelper.removeFavourite(widget.item.id); + + if (!mounted) return; + + setState(() { + widget.item.userData = newUserData; + }); + } catch (e) { + setState(() { + widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite; + }); + Vibrate.feedback(FeedbackType.error); + errorSnackbar(e, context); + } + } + + @override + Widget build(BuildContext context) { + final iconColor = _imageTheme?.primary ?? + Theme.of(context).iconTheme.color ?? + Colors.white; + + return Stack(children: [ + DraggableScrollableSheet( + snap: true, + snapSizes: widget.showPlaybackControls ? const [0.6] : const [0.45], + initialChildSize: widget.showPlaybackControls ? 0.6 : 0.45, + minChildSize: 0.15, + expand: false, + builder: (context, scrollController) { + return Stack( + children: [ + if (FinampSettingsHelper + .finampSettings.showCoverAsPlayerBackground) + BlurredPlayerScreenBackground( + customImageProvider: _imageProvider, + brightnessFactor: + Theme.of(context).brightness == Brightness.dark + ? 1.0 + : 1.0), + CustomScrollView( + controller: scrollController, + physics: const ClampingScrollPhysics(), + slivers: [ + SliverPersistentHeader( + delegate: SongMenuSliverAppBar( + item: widget.item, + theme: _imageTheme, + imageProviderCallback: (ImageProvider provider) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _imageProvider = provider; + }); + }); + }, + imageThemeCallback: (ColorScheme colorScheme) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _imageTheme = colorScheme; + }); + }); + }, + ), + pinned: true, + ), + if (widget.showPlaybackControls) + StreamBuilder( + stream: Rx.combineLatest2( + _queueService.getPlaybackOrderStream(), + _queueService.getLoopModeStream(), + (a, b) => PlaybackBehaviorInfo(a, b)), + builder: (context, snapshot) { + if (!snapshot.hasData) + return const SliverToBoxAdapter(); + + final playbackBehavior = snapshot.data!; + const playbackOrderIcons = { + FinampPlaybackOrder.linear: TablerIcons.arrows_right, + FinampPlaybackOrder.shuffled: + TablerIcons.arrows_shuffle, + }; + final playbackOrderTooltips = { + FinampPlaybackOrder.linear: + AppLocalizations.of(context) + ?.playbackOrderLinearButtonLabel ?? + "Playing in order", + FinampPlaybackOrder.shuffled: + AppLocalizations.of(context) + ?.playbackOrderShuffledButtonLabel ?? + "Shuffling", + }; + const loopModeIcons = { + FinampLoopMode.none: TablerIcons.repeat, + FinampLoopMode.one: TablerIcons.repeat_once, + FinampLoopMode.all: TablerIcons.repeat, + }; + final loopModeTooltips = { + FinampLoopMode.none: AppLocalizations.of(context) + ?.loopModeNoneButtonLabel ?? + "Looping off", + FinampLoopMode.one: AppLocalizations.of(context) + ?.loopModeOneButtonLabel ?? + "Looping this song", + FinampLoopMode.all: AppLocalizations.of(context) + ?.loopModeAllButtonLabel ?? + "Looping all", + }; + + return SliverCrossAxisGroup( + // return SliverGrid.count( + // crossAxisCount: 3, + // mainAxisSpacing: 40, + // children: [ + slivers: [ + PlaybackAction( + icon: playbackOrderIcons[playbackBehavior.order]!, + onPressed: () async { + _queueService.togglePlaybackOrder(); + }, + tooltip: playbackOrderTooltips[ + playbackBehavior.order]!, + iconColor: playbackBehavior.order == + FinampPlaybackOrder.shuffled + ? iconColor + : Theme.of(context) + .textTheme + .bodyMedium + ?.color ?? + Colors.white, + ), + ValueListenableBuilder( + valueListenable: _audioHandler.sleepTimer, + builder: (context, timerValue, child) { + final remainingMinutes = (_audioHandler + .sleepTimerRemaining.inSeconds / + 60.0) + .ceil(); + return PlaybackAction( + icon: timerValue != null + ? TablerIcons.hourglass_high + : TablerIcons.hourglass, + onPressed: () async { + if (timerValue != null) { + showDialog( + context: context, + builder: (context) => + const SleepTimerCancelDialog(), + ); + } else { + await showDialog( + context: context, + builder: (context) => + const SleepTimerDialog(), + ); + } + }, + tooltip: timerValue != null + ? AppLocalizations.of(context) + ?.sleepTimerRemainingTime( + remainingMinutes) ?? + "Sleeping in $remainingMinutes minutes" + : AppLocalizations.of(context)! + .sleepTimerTooltip, + iconColor: timerValue != null + ? iconColor + : Theme.of(context) + .textTheme + .bodyMedium + ?.color ?? + Colors.white, + ); + }, + ), + PlaybackAction( + icon: loopModeIcons[playbackBehavior.loop]!, + onPressed: () async { + _queueService.toggleLoopMode(); + }, + tooltip: loopModeTooltips[playbackBehavior.loop]!, + iconColor: + playbackBehavior.loop == FinampLoopMode.none + ? Theme.of(context) + .textTheme + .bodyMedium + ?.color ?? + Colors.white + : iconColor, + ), + ], + ); + }, + ), + SliverPadding( + padding: const EdgeInsets.only(left: 8.0), + sliver: SliverList( + delegate: SliverChildListDelegate([ + ListTile( + leading: widget.item.userData!.isFavorite + ? Icon( + Icons.favorite, + color: iconColor, + ) + : Icon( + Icons.favorite_border, + color: iconColor, + ), + title: Text(widget.item.userData!.isFavorite + ? AppLocalizations.of(context)!.removeFavourite + : AppLocalizations.of(context)!.addFavourite), + onTap: () async { + await toggleFavorite(); + if (mounted) Navigator.pop(context); + }, + ), + Visibility( + visible: _queueService.getQueue().nextUp.isNotEmpty, + child: ListTile( + leading: Icon( + TablerIcons.hourglass_low, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.playNext), + onTap: () async { + await _queueService.addNext( + items: [widget.item], + source: QueueItemSource( + type: QueueItemSourceType.nextUp, + name: QueueItemSourceName( + type: QueueItemSourceNameType + .preTranslated, + pretranslatedName: widget.item.name), + id: widget.item.id)); + + if (!mounted) return; + + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context)!.addedToQueue), + )); + Navigator.pop(context); + }, + ), + ), + ListTile( + leading: Icon( + TablerIcons.hourglass_high, + color: iconColor, + ), + title: + Text(AppLocalizations.of(context)!.addToNextUp), + onTap: () async { + await _queueService.addToNextUp( + items: [widget.item], + source: QueueItemSource( + type: QueueItemSourceType.nextUp, + name: QueueItemSourceName( + type: QueueItemSourceNameType + .preTranslated, + pretranslatedName: widget.item.name), + id: widget.item.id)); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context)!.addedToQueue), + )); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon( + Icons.queue_music, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.addToQueue), + onTap: () async { + await _queueService.addToQueue( + items: [widget.item], + source: QueueItemSource( + type: QueueItemSourceType.allSongs, + name: QueueItemSourceName( + type: QueueItemSourceNameType + .preTranslated, + pretranslatedName: widget.item.name), + id: widget.item.id)); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context)!.addedToQueue), + )); + Navigator.pop(context); + }, + ), + Visibility( + visible: widget.isInPlaylist && !widget.isOffline, + child: ListTile( + leading: Icon( + Icons.playlist_remove, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)! + .removeFromPlaylistTitle), + enabled: + !widget.isOffline && widget.parentId != null, + onTap: () async { + try { + await _jellyfinApiHelper + .removeItemsFromPlaylist( + playlistId: widget.parentId!, + entryIds: [ + widget.item.playlistItemId! + ]); + + if (!mounted) return; + + await _jellyfinApiHelper.getItems( + parentItem: await _jellyfinApiHelper + .getItemById(widget.item.parentId!), + sortBy: + "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + isGenres: false, + ); + + if (!mounted) return; + + if (widget.onRemoveFromList != null) + widget.onRemoveFromList!(); + + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)! + .removedFromPlaylist), + )); + Navigator.pop(context); + } catch (e) { + errorSnackbar(e, context); + } + }, + ), + ), + Visibility( + visible: !widget.isOffline, + child: ListTile( + leading: Icon( + Icons.playlist_add, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)! + .addToPlaylistTitle), + enabled: !widget.isOffline, + onTap: () { + Navigator.pop(context); + Navigator.of(context).pushNamed( + AddToPlaylistScreen.routeName, + arguments: widget.item.id); + }, + ), + ), + Visibility( + visible: !widget.isOffline, + child: ListTile( + leading: Icon( + Icons.explore, + color: iconColor, + ), + title: + Text(AppLocalizations.of(context)!.instantMix), + enabled: !widget.isOffline, + onTap: () async { + await _audioServiceHelper + .startInstantMixForItem(widget.item); + + if (!mounted) return; + + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)! + .startingInstantMix), + )); + Navigator.pop(context); + }, + ), + ), + Visibility( + visible: widget.canGoToAlbum, + child: ListTile( + leading: Icon( + Icons.album, + color: iconColor, + ), + title: + Text(AppLocalizations.of(context)!.goToAlbum), + enabled: widget.canGoToAlbum, + onTap: () async { + late BaseItemDto album; + if (FinampSettingsHelper + .finampSettings.isOffline) { + // If offline, load the album's BaseItemDto from DownloadHelper. + final downloadsHelper = + GetIt.instance(); + + // downloadedParent won't be null here since the menu item already + // checks if the DownloadedParent exists. + album = downloadsHelper + .getDownloadedParent(widget.item.parentId!)! + .item; + } else { + // If online, get the album's BaseItemDto from the server. + try { + album = await _jellyfinApiHelper + .getItemById(widget.item.parentId!); + } catch (e) { + errorSnackbar(e, context); + return; + } + } + if (mounted) { + Navigator.pop(context); + Navigator.of(context).pushNamed( + AlbumScreen.routeName, + arguments: album); + } + }, + ), + ), + Visibility( + visible: widget.canGoToArtist, + child: ListTile( + leading: Icon( + Icons.person, + color: iconColor, + ), + title: + Text(AppLocalizations.of(context)!.goToArtist), + enabled: widget.canGoToArtist, + onTap: () async { + late BaseItemDto artist; + // If online, get the artist's BaseItemDto from the server. + try { + artist = await _jellyfinApiHelper.getItemById( + widget.item.artistItems!.first.id); + } catch (e) { + errorSnackbar(e, context); + return; + } + if (mounted) { + Navigator.pop(context); + Navigator.of(context).pushNamed( + ArtistScreen.routeName, + arguments: artist); + } + }, + ), + ), + Visibility( + visible: widget.canGoToGenre, + child: ListTile( + leading: Icon( + Icons.category_outlined, + color: iconColor, + ), + title: + Text(AppLocalizations.of(context)!.goToGenre), + enabled: widget.canGoToGenre, + onTap: () async { + late BaseItemDto genre; + // If online, get the genre's BaseItemDto from the server. + try { + genre = await _jellyfinApiHelper.getItemById( + widget.item.genreItems!.first.id); + } catch (e) { + errorSnackbar(e, context); + return; + } + if (mounted) { + Navigator.pop(context); + Navigator.of(context).pushNamed( + ArtistScreen.routeName, + arguments: genre); + } + }, + ), + ), + ]), + ), + ) + ], + ), + ], + ); + }, + ), + ]); + } +} + +class SongMenuSliverAppBar extends SliverPersistentHeaderDelegate { + BaseItemDto item; + final ColorScheme? theme; + final Function(ColorScheme)? imageThemeCallback; + final Function(ImageProvider)? imageProviderCallback; + + SongMenuSliverAppBar({ + required this.item, + required this.theme, + this.imageThemeCallback, + this.imageProviderCallback, + }); + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return _SongInfo( + item: item, + theme: theme, + imageThemeCallback: imageThemeCallback, + imageProviderCallback: imageProviderCallback, + ); + } + + @override + double get maxExtent => 150; + + @override + double get minExtent => 100; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => + true; +} + +class _SongInfo extends ConsumerStatefulWidget { + const _SongInfo({ + required this.item, + required this.theme, + this.imageThemeCallback, + this.imageProviderCallback, + }); + + final BaseItemDto item; + final ColorScheme? theme; + final Function(ColorScheme)? imageThemeCallback; + final Function(ImageProvider)? imageProviderCallback; + + @override + ConsumerState<_SongInfo> createState() => _SongInfoState(); +} + +class _SongInfoState extends ConsumerState<_SongInfo> { + + final _queueService = GetIt.instance(); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.transparent, + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12.0), + height: 120, + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.black.withOpacity(0.25) + : Colors.white.withOpacity(0.15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 120, + height: 120, + child: AlbumImage( + item: widget.item, + borderRadius: BorderRadius.zero, + imageProviderCallback: (imageProvider) async { + if (widget.theme == null && imageProvider != null) { + if (widget.imageProviderCallback != null) { + widget.imageProviderCallback!(imageProvider); + } + + 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); + + final newColorScheme = ColorScheme.fromSwatch( + primarySwatch: generateMaterialColor(accent), + accentColor: accent, + brightness: theme.brightness, + ); + + if (widget.theme == null && + widget.imageThemeCallback != null) { + widget.imageThemeCallback!(newColorScheme); + } + } + }, + ), + ), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.item.name ?? + AppLocalizations.of(context)!.unknownName, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: 18, + height: 1.2, + color: + Theme.of(context).textTheme.bodyMedium?.color ?? + Colors.white, + ), + overflow: TextOverflow.ellipsis, + softWrap: true, + maxLines: 2, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: ArtistChips( + baseItem: widget.item, + backgroundColor: IconTheme.of(context) + .color + ?.withOpacity(0.1) ?? + Theme.of(context).textTheme.bodyMedium?.color ?? + Colors.white, + color: + Theme.of(context).textTheme.bodyMedium?.color ?? + Colors.white, + ), + ), + AlbumChip( + item: widget.item, + color: Theme.of(context).textTheme.bodyMedium?.color ?? + Colors.white, + backgroundColor: + IconTheme.of(context).color?.withOpacity(0.1) ?? + Theme.of(context).textTheme.bodyMedium?.color ?? + Colors.white, + key: widget.item.album == null + ? null + : ValueKey("${widget.item.album}-album"), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class PlaybackAction extends StatelessWidget { + const PlaybackAction({ + super.key, + required this.icon, + required this.onPressed, + required this.tooltip, + required this.iconColor, + }); + + final IconData icon; + final Function() onPressed; + final String tooltip; + final Color iconColor; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: IconButton( + icon: Column( + children: [ + Icon( + icon, + color: iconColor, + size: 32, + weight: 1.0, + ), + const SizedBox(height: 12), + SizedBox( + height: 2 * 12 * 1.4 + 2, + child: Align( + alignment: Alignment.topCenter, + child: Text( + tooltip, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.fade, + style: const TextStyle( + fontSize: 12, + height: 1.4, + fontWeight: FontWeight.w300, + ), + ), + ), + ), + ], + ), + onPressed: () { + Vibrate.feedback(FeedbackType.success); + onPressed(); + }, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.only( + top: 12.0, left: 12.0, right: 12.0, bottom: 16.0), + tooltip: tooltip, + ), + ); + } +} diff --git a/lib/components/PlayerScreen/album_chip.dart b/lib/components/PlayerScreen/album_chip.dart index d3bea7671..58bf65b9e 100644 --- a/lib/components/PlayerScreen/album_chip.dart +++ b/lib/components/PlayerScreen/album_chip.dart @@ -13,10 +13,12 @@ class AlbumChip extends StatelessWidget { const AlbumChip({ Key? key, this.item, + this.backgroundColor, this.color, }) : super(key: key); final BaseItemDto? item; + final Color? backgroundColor; final Color? color; @override @@ -25,7 +27,7 @@ class AlbumChip extends StatelessWidget { return Container( constraints: const BoxConstraints(minWidth: 10), - child: _AlbumChipContent(item: item!, color: color)); + child: _AlbumChipContent(item: item!, color: color, backgroundColor: backgroundColor,)); } } @@ -48,10 +50,12 @@ class _AlbumChipContent extends StatelessWidget { const _AlbumChipContent({ Key? key, required this.item, + required this.backgroundColor, required this.color, }) : super(key: key); final BaseItemDto item; + final Color? backgroundColor; final Color? color; @override @@ -59,7 +63,7 @@ class _AlbumChipContent extends StatelessWidget { final jellyfinApiHelper = GetIt.instance(); return Material( - color: color ?? Colors.white.withOpacity(0.1), + color: backgroundColor ?? Colors.white.withOpacity(0.1), borderRadius: _borderRadius, child: InkWell( borderRadius: _borderRadius, @@ -75,6 +79,9 @@ class _AlbumChipContent extends StatelessWidget { item.album ?? AppLocalizations.of(context)!.noAlbum, overflow: TextOverflow.ellipsis, softWrap: false, + style: TextStyle( + color: color ?? Theme.of(context).textTheme.bodySmall!.color ?? Colors.white, + ), ), ), ), diff --git a/lib/components/PlayerScreen/artist_chip.dart b/lib/components/PlayerScreen/artist_chip.dart index 0a6504477..a5a7b6f1d 100644 --- a/lib/components/PlayerScreen/artist_chip.dart +++ b/lib/components/PlayerScreen/artist_chip.dart @@ -11,19 +11,18 @@ import '../album_image.dart'; const _radius = Radius.circular(4); const _borderRadius = BorderRadius.all(_radius); const _height = 24.0; // I'm sure this magic number will work on all devices -final _defaultColour = Colors.white.withOpacity(0.1); -const _textStyle = TextStyle( - overflow: TextOverflow.fade, -); +final _defaultBackgroundColour = Colors.white.withOpacity(0.1); class ArtistChips extends StatelessWidget { const ArtistChips({ Key? key, + this.backgroundColor, this.color, this.baseItem, }) : super(key: key); final BaseItemDto? baseItem; + final Color? backgroundColor; final Color? color; @override @@ -39,6 +38,7 @@ class ArtistChips extends StatelessWidget { final currentArtist = baseItem!.artistItems![index]; return ArtistChip( + backgroundColor: backgroundColor, color: color, artist: BaseItemDto( id: currentArtist.id, @@ -56,11 +56,13 @@ class ArtistChips extends StatelessWidget { class ArtistChip extends StatefulWidget { const ArtistChip({ Key? key, + this.backgroundColor, this.color, this.artist, }) : super(key: key); final BaseItemDto? artist; + final Color? backgroundColor; final Color? color; @override @@ -95,9 +97,13 @@ class _ArtistChipState extends State { return FutureBuilder( future: _artistChipFuture, builder: (context, snapshot) { - final color = widget.color ?? _defaultColour; + final backgroundColor = widget.backgroundColor ?? _defaultBackgroundColour; + final color = widget.color ?? Theme.of(context).textTheme.bodySmall?.color ?? Colors.white; return _ArtistChipContent( - item: snapshot.data ?? widget.artist!, color: color); + item: snapshot.data ?? widget.artist!, + backgroundColor: backgroundColor, + color: color, + ); }); } } @@ -106,10 +112,12 @@ class _ArtistChipContent extends StatelessWidget { const _ArtistChipContent({ Key? key, required this.item, + required this.backgroundColor, required this.color, }) : super(key: key); final BaseItemDto item; + final Color backgroundColor; final Color color; @override @@ -122,7 +130,7 @@ class _ArtistChipContent extends StatelessWidget { return SizedBox( height: 24, child: Material( - color: color, + color: backgroundColor, borderRadius: _borderRadius, child: InkWell( // Offline artists aren't implemented and we shouldn't click through @@ -150,7 +158,10 @@ class _ArtistChipContent extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( name ?? AppLocalizations.of(context)!.unknownArtist, - style: _textStyle, + style: TextStyle( + color: color, + overflow: TextOverflow.ellipsis + ), softWrap: false, overflow: TextOverflow.ellipsis, ), diff --git a/lib/components/PlayerScreen/player_buttons_more.dart b/lib/components/PlayerScreen/player_buttons_more.dart index 415e37af5..96abedc7d 100644 --- a/lib/components/PlayerScreen/player_buttons_more.dart +++ b/lib/components/PlayerScreen/player_buttons_more.dart @@ -1,63 +1,52 @@ import 'package:audio_service/audio_service.dart'; +import 'package:finamp/components/AlbumScreen/song_list_tile.dart'; +import 'package:finamp/components/AlbumScreen/song_menu.dart'; import 'package:finamp/components/PlayerScreen/sleep_timer_button.dart'; import 'package:finamp/models/jellyfin_models.dart'; import 'package:finamp/screens/add_to_playlist_screen.dart'; import 'package:finamp/services/music_player_background_task.dart'; +import 'package:finamp/services/player_screen_theme_provider.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; enum PlayerButtonsMoreItems { shuffle, repeat, addToPlaylist, sleepTimer } -class PlayerButtonsMore extends StatelessWidget { +class PlayerButtonsMore extends ConsumerWidget { final audioHandler = GetIt.instance(); + BaseItemDto? item; - PlayerButtonsMore({Key? key}) : super(key: key); + PlayerButtonsMore({Key? key, required this.item}) : super(key: key); @override - Widget build(BuildContext context) { - return PopupMenuButton( - onSelected: (value) {}, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(15), - ), - ), - icon: Icon( - TablerIcons.menu_2, - color: IconTheme.of(context).color!, + Widget build(BuildContext context, WidgetRef ref) { + ColorScheme? colorScheme = ref.watch(playerScreenThemeProvider(Theme.of(context).brightness)); + return IconTheme( + data: IconThemeData( + color: colorScheme == null + ? (Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white) + : colorScheme.primary, ), - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem( - value: PlayerButtonsMoreItems.addToPlaylist, - child: StreamBuilder( - stream: audioHandler.mediaItem, - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListTile( - leading: const Icon(TablerIcons.playlist_add), - onTap: () => Navigator.of(context).pushReplacementNamed( - AddToPlaylistScreen.routeName, - arguments: BaseItemDto.fromJson( - snapshot.data!.extras!["itemJson"]) - .id), - title: Text(AppLocalizations.of(context)! - .addToPlaylistTooltip)); - } else { - return ListTile( - leading: const Icon(TablerIcons.playlist_add), - onTap: () {}, - title: Text(AppLocalizations.of(context)! - .addToPlaylistTooltip)); - } - })), - const PopupMenuItem( - value: PlayerButtonsMoreItems.sleepTimer, - child: SleepTimerButton(), + child: IconButton( + icon: const Icon( + TablerIcons.menu_2, ), - ], + onPressed: () async { + if (item == null) return; + final canGoToAlbum = item!.albumId != item!.parentId && + isAlbumDownloadedIfOffline(item!.parentId); + await showModalSongMenu( + context: context, + item: item!, + showPlaybackControls: true, // show controls on player screen + isInPlaylist: false, + parentId: item!.parentId); + }, + ), ); } } diff --git a/lib/components/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart index ede234ae1..b5c318fe6 100644 --- a/lib/components/PlayerScreen/queue_list.dart +++ b/lib/components/PlayerScreen/queue_list.dart @@ -812,8 +812,8 @@ class _CurrentTrackState extends State { currentTrack?.item.title ?? AppLocalizations.of(context)! .unknownName, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge?.color ?? Colors.white, fontSize: 16, fontFamily: 'Lexend Deca', fontWeight: FontWeight.w500, @@ -830,7 +830,7 @@ class _CurrentTrackState extends State { currentTrack!.item.artist, context), style: TextStyle( - color: Colors.white + color: (Theme.of(context).textTheme.bodyMedium?.color ?? Colors.white) .withOpacity(0.85), fontSize: 13, fontFamily: 'Lexend Deca', @@ -850,7 +850,7 @@ class _CurrentTrackState extends State { builder: (context, snapshot) { final TextStyle style = TextStyle( - color: Colors.white + color: (Theme.of(context).textTheme.bodyMedium?.color ?? Colors.white) .withOpacity(0.8), fontSize: 14, fontFamily: 'Lexend Deca', @@ -880,7 +880,7 @@ class _CurrentTrackState extends State { Text( '/', style: TextStyle( - color: Colors.white + color: (Theme.of(context).textTheme.bodyMedium?.color ?? Colors.white) .withOpacity(0.8), fontSize: 14, fontFamily: 'Lexend Deca', @@ -897,7 +897,7 @@ class _CurrentTrackState extends State { ? "${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 + color: (Theme.of(context).textTheme.bodyMedium?.color ?? Colors.white) .withOpacity(0.8), fontSize: 14, fontFamily: 'Lexend Deca', @@ -935,16 +935,16 @@ class _CurrentTrackState extends State { fill: 1.0, weight: 1.5, ) - : const Icon( + : Icon( Icons.favorite_outline, size: 28, - color: Colors.white, + color: IconTheme.of(context).color, weight: 1.5, ), onPressed: () { Vibrate.feedback(FeedbackType.success); setState(() { - setFavourite(currentTrack!); + setFavourite(currentTrack!, context); }); }, ), @@ -954,10 +954,10 @@ class _CurrentTrackState extends State { visualDensity: const VisualDensity(horizontal: -4), // visualDensity: VisualDensity.compact, - icon: const Icon( + icon: Icon( TablerIcons.dots_vertical, size: 28, - color: Colors.white, + color: IconTheme.of(context).color, weight: 1.5, ), onPressed: () => @@ -1143,14 +1143,19 @@ class _CurrentTrackState extends State { break; case SongListTileMenuItems.addFavourite: case SongListTileMenuItems.removeFavourite: - await setFavourite(currentTrack); + await setFavourite(currentTrack, context); break; case null: break; } } - Future setFavourite(FinampQueueItem track) async { +} + +Future setFavourite(FinampQueueItem track, BuildContext context) async { + final queueService = GetIt.instance(); + final jellyfinApiHelper = GetIt.instance(); + try { // We switch the widget state before actually doing the request to // make the app feel faster (without, there is a delay from the @@ -1158,30 +1163,29 @@ class _CurrentTrackState extends State { jellyfin_models.BaseItemDto item = jellyfin_models.BaseItemDto.fromJson(track.item.extras!["itemJson"]); - setState(() { + // setState(() { item.userData!.isFavorite = !item.userData!.isFavorite; - }); + // }); // Since we flipped the favourite state already, we can use the flipped // state to decide which API call to make final newUserData = item.userData!.isFavorite - ? await _jellyfinApiHelper.addFavourite(item.id) - : await _jellyfinApiHelper.removeFavourite(item.id); + ? await jellyfinApiHelper.addFavourite(item.id) + : await jellyfinApiHelper.removeFavourite(item.id); item.userData = newUserData; - if (!mounted) return; - setState(() { + // if (!mounted) return; + // setState(() { //!!! update the QueueItem with the new BaseItemDto, then trigger a rebuild of the widget with the current snapshot (**which includes the modified QueueItem**) track.item.extras!["itemJson"] = item.toJson(); - }); + // }); - _queueService.refreshQueueStream(); + queueService.refreshQueueStream(); } catch (e) { errorSnackbar(e, context); } } -} class PlaybackBehaviorInfo { final FinampPlaybackOrder order; @@ -1247,7 +1251,7 @@ class QueueSectionHeader extends SliverPersistentHeaderDelegate { )), color: info?.order == FinampPlaybackOrder.shuffled ? IconTheme.of(context).color! - : Colors.white, + : (Theme.of(context).textTheme.bodyMedium?.color ?? Colors.white).withOpacity(0.85), onPressed: () { queueService.togglePlaybackOrder(); Vibrate.feedback(FeedbackType.success); @@ -1274,7 +1278,7 @@ class QueueSectionHeader extends SliverPersistentHeaderDelegate { )), color: info?.loop != FinampLoopMode.none ? IconTheme.of(context).color! - : Colors.white, + : (Theme.of(context).textTheme.bodyMedium?.color ?? Colors.white).withOpacity(0.85), onPressed: () { queueService.toggleLoopMode(); Vibrate.feedback(FeedbackType.success); diff --git a/lib/components/PlayerScreen/queue_list_item.dart b/lib/components/PlayerScreen/queue_list_item.dart index 32639e69f..f411e7312 100644 --- a/lib/components/PlayerScreen/queue_list_item.dart +++ b/lib/components/PlayerScreen/queue_list_item.dart @@ -1,4 +1,5 @@ import 'package:finamp/components/AlbumScreen/song_list_tile.dart'; +import 'package:finamp/components/AlbumScreen/song_menu.dart'; import 'package:finamp/components/album_image.dart'; import 'package:finamp/components/error_snackbar.dart'; import 'package:finamp/screens/add_to_playlist_screen.dart'; @@ -57,6 +58,9 @@ class _QueueListItemState extends State Widget build(BuildContext context) { super.build(context); + jellyfin_models.BaseItemDto baseItem = jellyfin_models.BaseItemDto.fromJson( + widget.item.item.extras?["itemJson"]); + return Dismissible( key: Key(widget.item.id), onDismissed: (direction) async { @@ -65,7 +69,10 @@ class _QueueListItemState extends State setState(() {}); }, child: GestureDetector( - onLongPressStart: (details) => showSongMenu(details), + onLongPressStart: (details) => showModalSongMenu( + context: context, + item: baseItem, + parentId: widget.item.source.id), child: Opacity( opacity: widget.isPreviousTrack ? 0.8 : 1.0, child: Card( @@ -152,11 +159,11 @@ class _QueueListItemState extends State if (widget.allowReorder) ReorderableDragStartListener( index: widget.listIndex, - child: const Padding( - padding: EdgeInsets.only(bottom: 5.0, left: 6.0), + child: Padding( + padding: const EdgeInsets.only(bottom: 5.0, left: 6.0), child: Icon( TablerIcons.grip_horizontal, - color: Colors.white, + color: Theme.of(context).textTheme.bodyMedium?.color ?? Colors.white, size: 28.0, weight: 1.5, ), diff --git a/lib/components/PlayerScreen/sleep_timer_button.dart b/lib/components/PlayerScreen/sleep_timer_button.dart index d14911be8..c7f35eb9d 100644 --- a/lib/components/PlayerScreen/sleep_timer_button.dart +++ b/lib/components/PlayerScreen/sleep_timer_button.dart @@ -2,18 +2,20 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.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 '../../services/music_player_background_task.dart'; +import '../../services/player_screen_theme_provider.dart'; import 'sleep_timer_dialog.dart'; import 'sleep_timer_cancel_dialog.dart'; -class SleepTimerButton extends StatelessWidget { +class SleepTimerButton extends ConsumerWidget { const SleepTimerButton({Key? key}) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final audioHandler = GetIt.instance(); return ValueListenableBuilder( diff --git a/lib/components/PlayerScreen/song_name_content.dart b/lib/components/PlayerScreen/song_name_content.dart index 3414afda7..e93e5edc5 100644 --- a/lib/components/PlayerScreen/song_name_content.dart +++ b/lib/components/PlayerScreen/song_name_content.dart @@ -63,11 +63,11 @@ class SongNameContent extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - PlayerButtonsMore(), + PlayerButtonsMore(item: songBaseItemDto), Flexible( child: ArtistChips( baseItem: songBaseItemDto, - color: IconTheme.of(context).color!.withOpacity(0.1), + backgroundColor: IconTheme.of(context).color!.withOpacity(0.1), key: songBaseItemDto?.albumArtist == null ? null // We have to add -artist and -album to the keys because otherwise @@ -91,7 +91,7 @@ class SongNameContent extends StatelessWidget { ), AlbumChip( item: songBaseItemDto, - color: IconTheme.of(context).color!.withOpacity(0.1), + backgroundColor: IconTheme.of(context).color!.withOpacity(0.1), key: songBaseItemDto?.album == null ? null : ValueKey("${songBaseItemDto!.album}-album"), diff --git a/lib/components/album_image.dart b/lib/components/album_image.dart index a6d78f32e..e50d70cbd 100644 --- a/lib/components/album_image.dart +++ b/lib/components/album_image.dart @@ -16,6 +16,7 @@ class AlbumImage extends ConsumerWidget { Key? key, this.item, this.imageListenable, + this.imageProviderCallback, this.borderRadius, this.placeholderBuilder, }) : super(key: key); @@ -25,6 +26,9 @@ class AlbumImage extends ConsumerWidget { final ProviderListenable>? imageListenable; + /// A callback to get the image provider once it has been fetched. + final ImageProviderCallback? imageProviderCallback; + final BorderRadius? borderRadius; final WidgetBuilder? placeholderBuilder; @@ -37,6 +41,11 @@ class AlbumImage extends ConsumerWidget { assert(item == null || imageListenable == null); if ((item == null || item!.imageId == null) && imageListenable == null) { + + if (imageProviderCallback != null) { + imageProviderCallback!(null); + } + return ClipRRect( borderRadius: borderRadius, child: const AspectRatio( @@ -68,6 +77,7 @@ class AlbumImage extends ConsumerWidget { maxWidth: physicalWidth, maxHeight: physicalHeight, )), + imageProviderCallback: imageProviderCallback, placeholderBuilder: placeholderBuilder ?? BareAlbumImage.defaultPlaceholderBuilder, ); @@ -82,6 +92,7 @@ class BareAlbumImage extends ConsumerWidget { const BareAlbumImage({ Key? key, required this.imageListenable, + this.imageProviderCallback, this.errorBuilder = defaultErrorBuilder, this.placeholderBuilder = defaultPlaceholderBuilder, }) : super(key: key); @@ -89,6 +100,7 @@ class BareAlbumImage extends ConsumerWidget { final ProviderListenable> imageListenable; final WidgetBuilder placeholderBuilder; final OctoErrorBuilder errorBuilder; + final ImageProviderCallback? imageProviderCallback; static Widget defaultPlaceholderBuilder(BuildContext context) { return Container(color: Theme.of(context).cardColor); @@ -103,6 +115,9 @@ class BareAlbumImage extends ConsumerWidget { AsyncValue image = ref.watch(imageListenable); if (image.hasValue && image.value != null) { + if (imageProviderCallback != null) { + imageProviderCallback!(image.value); + } return OctoImage( image: image.value!, fit: BoxFit.cover, @@ -112,9 +127,16 @@ class BareAlbumImage extends ConsumerWidget { } if (image.hasError) { + if (imageProviderCallback != null) { + imageProviderCallback!(null); + } return const _AlbumImageErrorPlaceholder(); } + if (imageProviderCallback != null) { + imageProviderCallback!(null); + } + return Builder(builder: placeholderBuilder); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 69ac7cbc1..1060b612d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -382,6 +382,15 @@ "@invalidNumber": {}, "sleepTimerTooltip": "Sleep timer", "@sleepTimerTooltip": {}, + "sleepTimerRemainingTime": "Sleeping in {time} minutes", + "@sleepTimerRemainingTime": { + "description": "Button label for sleep timer. {time} is the amount of minutes left.", + "placeholders": { + "time": { + "type": "int" + } + } + }, "addToPlaylistTooltip": "Add to playlist", "@addToPlaylistTooltip": {}, "addToPlaylistTitle": "Add to Playlist", @@ -426,6 +435,10 @@ "@instantMix": {}, "goToAlbum": "Go to Album", "@goToAlbum": {}, + "goToArtist": "Go to Artist", + "@goToArtist": {}, + "goToGenre": "Go to Genre", + "@goToGenre": {}, "removeFavourite": "Remove Favourite", "@removeFavourite": {}, "addFavourite": "Add Favourite", @@ -602,6 +615,26 @@ "@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" }, + "playbackOrderLinearButtonLabel": "Playing in order", + "@playbackOrderLinearButtonLabel": { + "description": "Label for the button that toggles the playback order between linear/in-order and shuffle, while the queue is in linear/in-order mode" + }, + "playbackOrderShuffledButtonLabel": "Shuffling songs", + "@playbackOrderShuffledButtonLabel": { + "description": "Label for the button that toggles the playback order between linear/in-order and shuffle, while the queue is in shuffle mode" + }, + "loopModeNoneButtonLabel": "Looping off", + "@loopModeNoneButtonLabel": { + "description": "Label for the button that toggles the loop mode between off, loop one, and loop all, while the queue is in loop off mode" + }, + "loopModeOneButtonLabel": "Looping this song", + "@loopModeOneButtonLabel": { + "description": "Label for the button that toggles the loop mode between off, loop one, and loop all, while the queue is in loop one mode" + }, + "loopModeAllButtonLabel": "Looping all", + "@loopModeAllButtonLabel": { + "description": "Label for the button that toggles the loop mode between off, loop one, and loop all, while the queue is in loop all mode" + }, "queuesScreen": "Restore Now Playing", "@queuesScreen": { "description": "Title for the screen where older now playing queues can be restored" diff --git a/lib/screens/blurred_player_screen_background.dart b/lib/screens/blurred_player_screen_background.dart index 9ad3d18c2..cb0b3afba 100644 --- a/lib/screens/blurred_player_screen_background.dart +++ b/lib/screens/blurred_player_screen_background.dart @@ -11,15 +11,17 @@ import '../services/current_album_image_provider.dart'; class BlurredPlayerScreenBackground extends ConsumerWidget { /// should never be less than 1.0 final double brightnessFactor; + final ImageProvider? customImageProvider; const BlurredPlayerScreenBackground({ Key? key, + this.customImageProvider, this.brightnessFactor = 1.0, }) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - final imageProvider = ref.watch(currentAlbumImageProvider).value; + final imageProvider = customImageProvider ?? ref.watch(currentAlbumImageProvider).value; return AnimatedSwitcher( duration: const Duration(milliseconds: 1000), diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index 9d06e142e..b82a64e5e 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:simple_gesture_detector/simple_gesture_detector.dart'; import '../components/PlayerScreen/control_area.dart'; +import '../components/PlayerScreen/progress_slider.dart'; +import '../components/PlayerScreen/sleep_timer_button.dart'; import '../components/PlayerScreen/song_info.dart'; import '../components/PlayerScreen/queue_button.dart'; import '../components/finamp_app_bar_button.dart'; diff --git a/lib/services/download_update_stream.dart b/lib/services/download_update_stream.dart index d7d1a2cc0..9de6a0cd7 100644 --- a/lib/services/download_update_stream.dart +++ b/lib/services/download_update_stream.dart @@ -31,7 +31,7 @@ class DownloadUpdateStream { _port.sendPort, 'downloader_send_port'); _port.listen((dynamic data) { String id = data[0]; - DownloadTaskStatus status = DownloadTaskStatus(data[1]); + DownloadTaskStatus status = DownloadTaskStatus.fromInt(data[1]); int progress = data[2]; add(DownloadUpdate( diff --git a/lib/services/downloads_helper.dart b/lib/services/downloads_helper.dart index 2d9791936..4ae491f72 100644 --- a/lib/services/downloads_helper.dart +++ b/lib/services/downloads_helper.dart @@ -415,7 +415,7 @@ class DownloadsHelper { try { return await FlutterDownloader.loadTasksWithRawQuery( query: - "SELECT * FROM task WHERE status = ${downloadTaskStatus.value}"); + "SELECT * FROM task WHERE status = $downloadTaskStatus.index"); } catch (e) { _downloadsLogger.severe(e); return Future.error(e); diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 83578c92c..0dc8806b8 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -37,6 +37,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { /// null. bool _sleepTimerIsSet = false; Duration _sleepTimerDuration = Duration.zero; + DateTime _sleepTimerStartTime = DateTime.now(); final ValueNotifier _sleepTimer = ValueNotifier(null); Future Function()? _queueCallbackPreviousTrack; @@ -310,6 +311,7 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { Timer setSleepTimer(Duration duration) { _sleepTimerIsSet = true; _sleepTimerDuration = duration; + _sleepTimerStartTime = DateTime.now(); _sleepTimer.value = Timer(duration, () async { _sleepTimer.value = null; @@ -327,6 +329,16 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { _sleepTimer.value = null; } + Duration get sleepTimerRemaining { + if (_sleepTimer.value == null) { + return Duration.zero; + } else { + return _sleepTimerStartTime + .add(_sleepTimerDuration) + .difference(DateTime.now()); + } + } + /// Transform a just_audio event into an audio_service state. /// /// This method is used from the constructor. Every event received from the diff --git a/pubspec.lock b/pubspec.lock index 32627e1dd..7fec9b193 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: "direct main" description: name: android_id - sha256: f417b2fe86f93a1184662eaae582082c02c57d50c5a1048e5a66e09b2bdf87db + sha256: b7c55d9c30a7f02235607bd025e1e20e5573543fedf9f68018088c8535200e79 url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.3" archive: dependency: transitive description: @@ -325,10 +325,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: b85eb92b175767fdaa0c543bf3b0d1f610fe966412ea72845fe5ba7801e763ff + sha256: "9d6e95ec73abbd31ec54d0e0df8a961017e165aba1395e462e5b31ea0c165daf" url: "https://pub.dev" source: hosted - version: "5.2.10" + version: "5.3.1" file_sizes: dependency: "direct main" description: @@ -361,12 +361,11 @@ packages: flutter_downloader: dependency: "direct main" description: - path: "." - ref: ac9b9e917e874f1f86f52ddd74fb57e4e2af3639 - resolved-ref: ac9b9e917e874f1f86f52ddd74fb57e4e2af3639 - url: "https://github.com/jmshrv/flutter_downloader.git" - source: git - version: "1.10.2" + name: flutter_downloader + sha256: "36c16cc6657274f3cf7ccb681aeca89df62531ff5956f85e175f6be4b8d6b140" + url: "https://pub.dev" + source: hosted + version: "1.11.5" flutter_launcher_icons: dependency: "direct dev" description: @@ -885,10 +884,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: b1f15232d41e9701ab2f04181f21610c36c83a12ae426b79b4bd011c567934b1 + sha256: f74fc3f1cbd99f39760182e176802f693fa0ec9625c045561cfad54681ea93dd url: "https://pub.dev" source: hosted - version: "6.3.4" + version: "7.2.1" share_plus_platform_interface: dependency: transitive description: @@ -1138,10 +1137,10 @@ packages: dependency: transitive description: name: win32 - sha256: a6f0236dbda0f63aa9a25ad1ff9a9d8a4eaaa5012da0dc59d21afdb1dc361ca4 + sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "4.1.4" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7c543b2b3..8a90c9610 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A new Flutter project. # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -34,10 +34,6 @@ dependencies: audio_session: ^0.1.16 rxdart: ^0.27.7 simple_gesture_detector: ^0.2.0 - flutter_downloader: - git: - url: https://github.com/jmshrv/flutter_downloader.git - ref: "ac9b9e917e874f1f86f52ddd74fb57e4e2af3639" path_provider: ^2.0.14 hive: ^2.2.3 hive_flutter: ^1.1.0 @@ -54,13 +50,13 @@ dependencies: device_info_plus: ^8.2.0 package_info_plus: ^3.1.0 octo_image: ^2.0.0 - share_plus: ^6.3.2 + share_plus: ^7.0.2 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.5 path: ^1.8.2 - android_id: ^0.2.0 + android_id: ^0.3.3 intl: ^0.18.0 auto_size_text: ^3.0.0 palette_generator: @@ -84,6 +80,7 @@ dependencies: url: https://github.com/lamarios/locale_names.git ref: cea057c220f4ee7e09e8f1fc7036110245770948 flutter_vibrate: ^1.3.0 + flutter_downloader: ^1.11.5 dev_dependencies: flutter_test: @@ -92,7 +89,7 @@ dev_dependencies: chopper_generator: ^6.0.0 hive_generator: ^2.0.0 json_serializable: ^6.6.1 - flutter_launcher_icons: ^0.13.0 + flutter_launcher_icons: ^0.13.1 flutter_lints: ^2.0.1 # For information on the generic Dart part of this file, see the @@ -100,7 +97,6 @@ dev_dependencies: # The following section is specific to Flutter. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. @@ -159,8 +155,6 @@ flutter: weight: 800 - asset: assets/fonts/LexendDeca-Black.ttf weight: 900 - - flutter_icons: android: true