diff --git a/lib/components/AlbumScreen/download_button.dart b/lib/components/AlbumScreen/download_button.dart index 955dfa0ac..d8058a794 100644 --- a/lib/components/AlbumScreen/download_button.dart +++ b/lib/components/AlbumScreen/download_button.dart @@ -32,8 +32,10 @@ class DownloadButton extends ConsumerWidget { .select((value) => value.valueOrNull?.isOffline)) ?? true; String? parentTooltip; - if (status == DownloadItemStatus.incidental || - status == DownloadItemStatus.incidentalOutdated) { + if (status == null) { + return const SizedBox.shrink(); + } + if (status.isIncidental) { var parent = downloadsService.getFirstRequiringItem(item); if (parent != null) { var parentName = AppLocalizations.of(context)! @@ -42,9 +44,6 @@ class DownloadButton extends ConsumerWidget { AppLocalizations.of(context)!.incidentalDownloadTooltip(parentName); } } - if (status == null) { - return const SizedBox.shrink(); - } String viewId; if (isLibrary) { viewId = item.id; diff --git a/lib/components/AlbumScreen/song_menu.dart b/lib/components/AlbumScreen/song_menu.dart index 7cdc27ce0..bb53bba7d 100644 --- a/lib/components/AlbumScreen/song_menu.dart +++ b/lib/components/AlbumScreen/song_menu.dart @@ -10,6 +10,7 @@ import 'package:finamp/services/music_player_background_task.dart'; import 'package:finamp/services/queue_service.dart'; import 'package:finamp/services/feedback_helper.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.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'; @@ -137,18 +138,18 @@ class _SongMenuState extends State { /// Sets the item's favourite on the Jellyfin server. Future toggleFavorite() async { try { - final isOffline = FinampSettingsHelper.finampSettings.isOffline; if (isOffline) { FeedbackHelper.feedback(FeedbackType.error); - GlobalSnackbar.message((context) => AppLocalizations.of(context)!.notAvailableInOfflineMode); + GlobalSnackbar.message((context) => + AppLocalizations.of(context)!.notAvailableInOfflineMode); return; } - + final currentTrack = _queueService.getCurrentTrack(); if (isBaseItemInQueueItem(widget.item, currentTrack)) { - setFavourite(currentTrack!, context); + await setFavourite(currentTrack!, context); FeedbackHelper.feedback(FeedbackType.success); return; } @@ -177,7 +178,7 @@ class _SongMenuState extends State { widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite; }); FeedbackHelper.feedback(FeedbackType.error); - errorSnackbar(e, context); + GlobalSnackbar.error(e); } } @@ -187,537 +188,567 @@ class _SongMenuState extends State { Theme.of(context).iconTheme.color ?? Colors.white; - final downloadsService = GetIt.instance(); - final bool isDownloadRequired = downloadsService - .getStatus( - DownloadStub.fromItem( - type: DownloadItemType.song, item: widget.item), - null) - .isRequired; + final menuEntries = _menuEntries(context, iconColor); + var stackHeight = widget.showPlaybackControls ? 255 : 155; + stackHeight += menuEntries + .where((element) => + switch (element) { Visibility e => e.visible, _ => true }) + .length * + 56; return Stack(children: [ - DraggableScrollableSheet( - snap: true, - snapSizes: widget.showPlaybackControls ? const [0.6] : const [0.45], - initialChildSize: widget.showPlaybackControls ? 0.6 : 0.45, - minChildSize: 0.3, - expand: false, - builder: (context, scrollController) { - return Stack( - children: [ - if (FinampSettingsHelper - .finampSettings.showCoverAsPlayerBackground) - BlurredPlayerScreenBackground( - customImageProvider: _imageProvider, - opacityFactor: - 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((_) { - if (mounted) { - setState(() { - _imageProvider = provider; - }); - } - }); - }, - imageThemeCallback: (ColorScheme colorScheme) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - 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_empty, - 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, - ), - ], - ); - }, + LayoutBuilder(builder: (context, constraints) { + var size = (stackHeight / constraints.maxHeight).clamp(0.4, 1.0); + return DraggableScrollableSheet( + snap: true, + initialChildSize: size, + minChildSize: size * 0.9, + expand: false, + builder: (context, scrollController) { + return Stack( + children: [ + if (FinampSettingsHelper + .finampSettings.showCoverAsPlayerBackground) + BlurredPlayerScreenBackground( + customImageProvider: _imageProvider, + opacityFactor: + 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((_) { + if (mounted) { + setState(() { + _imageProvider = provider; + }); + } + }); + }, + imageThemeCallback: (ColorScheme colorScheme) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _imageTheme = colorScheme; + }); + } + }); + }, + ), + pinned: true, ), - SliverPadding( - padding: const EdgeInsets.only(left: 8.0), - sliver: SliverList( - delegate: SliverChildListDelegate([ - 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); // close menu - Navigator.of(context).pushNamed( - AddToPlaylistScreen.routeName, - arguments: widget.item.id); - }, - ), - ), - ListTile( - enabled: !widget.isOffline, - leading: widget.item.userData!.isFavorite - ? Icon( - Icons.favorite, - color: widget.isOffline ? iconColor.withOpacity(0.3) : iconColor, - ) - : Icon( - Icons.favorite_border, - color: widget.isOffline ? iconColor.withOpacity(0.3) : 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.corner_right_down, - color: iconColor, - ), - title: Text(AppLocalizations.of(context)!.playNext), - onTap: () async { - await _queueService.addNext( - items: [widget.item], - source: QueueItemSource( - type: QueueItemSourceType.nextUp, - name: const QueueItemSourceName( - type: QueueItemSourceNameType.nextUp), - id: widget.item.id)); - - if (!mounted) return; - - GlobalSnackbar.message((context) => - AppLocalizations.of(context)!.confirmPlayNext("track"), isConfirmation: true); - Navigator.pop(context); - }, - ), - ), - ListTile( - leading: Icon( - TablerIcons.corner_right_down_double, - color: iconColor, - ), - title: - Text(AppLocalizations.of(context)!.addToNextUp), - onTap: () async { - await _queueService.addToNextUp( - items: [widget.item], - source: QueueItemSource( - type: QueueItemSourceType.nextUp, - name: const QueueItemSourceName( - type: QueueItemSourceNameType.nextUp), - id: widget.item.id)); - - if (!mounted) return; - - GlobalSnackbar.message((context) => - AppLocalizations.of(context)!.confirmAddToNextUp("track"), isConfirmation: true); - Navigator.pop(context); - }, - ), - ListTile( - leading: Icon( - TablerIcons.playlist, - color: iconColor, - ), - title: Text(AppLocalizations.of(context)!.addToQueue), - onTap: () async { - await _queueService.addToQueue( - items: [widget.item], - source: QueueItemSource( - type: QueueItemSourceType.queue, - name: const QueueItemSourceName( - type: QueueItemSourceNameType.queue), - id: widget.item.id)); - - if (!mounted) return; - - GlobalSnackbar.message((context) => - AppLocalizations.of(context)!.addedToQueue, isConfirmation: true); - Navigator.pop(context); - }, - ), - Visibility( - visible: widget.isInPlaylist && widget.parentItem != null && !widget.isOffline, - child: ListTile( - leading: Icon( - Icons.playlist_remove, - color: iconColor, - ), - title: Text(AppLocalizations.of(context)! - .removeFromPlaylistTitle), - enabled: widget.isInPlaylist && widget.parentItem != null && !widget.isOffline, - onTap: () async { - try { - await _jellyfinApiHelper - .removeItemsFromPlaylist( - playlistId: widget.parentItem!.id, - entryIds: [ - widget.item.playlistItemId! - ]); - - if (!mounted) return; - - await _jellyfinApiHelper.getItems( - parentItem: await _jellyfinApiHelper - .getItemById(widget.item.parentId!), - sortBy: - "ParentIndexNumber,IndexNumber,SortName", - includeItemTypes: "Audio", - ); - - if (!mounted) return; - - if (widget.onRemoveFromList != null) - widget.onRemoveFromList!(); - - GlobalSnackbar.message((context) => - AppLocalizations.of(context)!.removedFromPlaylist, isConfirmation: true); - Navigator.pop(context); - } catch (e) { - GlobalSnackbar.error(e); - } - }, - ), - ), - 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; - - GlobalSnackbar.message((context) => - AppLocalizations.of(context)!.startingInstantMix, isConfirmation: true); - 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; - try { - if (FinampSettingsHelper - .finampSettings.isOffline) { - final downloadsService = - GetIt.instance(); - album = - (await downloadsService.getCollectionInfo( - id: widget.item.albumId!))! - .baseItem!; - } else { - album = await _jellyfinApiHelper - .getItemById(widget.item.albumId!); - } - } catch (e) { - GlobalSnackbar.error(e); - 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; - try { - if (FinampSettingsHelper - .finampSettings.isOffline) { - final downloadsService = - GetIt.instance(); - artist = - (await downloadsService.getCollectionInfo( - id: widget - .item.artistItems!.first.id))! - .baseItem!; - } else { - artist = await _jellyfinApiHelper.getItemById( - widget.item.artistItems!.first.id); - } - } catch (e) { - GlobalSnackbar.error(e); - 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; - try { - if (FinampSettingsHelper - .finampSettings.isOffline) { - final downloadsService = - GetIt.instance(); - genre = - (await downloadsService.getCollectionInfo( - id: widget - .item.genreItems!.first.id))! - .baseItem!; - } else { - genre = await _jellyfinApiHelper.getItemById( - widget.item.genreItems!.first.id); - } - } catch (e) { - GlobalSnackbar.error(e); - return; - } - if (mounted) { - Navigator.pop(context); - Navigator.of(context).pushNamed( - ArtistScreen.routeName, - arguments: genre); - } - }, - ), - ), - Visibility( - visible: isDownloadRequired, - // TODO add some sort of disabled state with tooltip saying to delete the parent - // Need to do on other delete buttons too - // Do we want to try showing lock on right clicks? - // Currently only download or delete are shown. - child: ListTile( - leading: Icon( - Icons.delete_outlined, - color: iconColor, - ), - title: - Text(AppLocalizations.of(context)!.deleteItem), - enabled: !widget.isOffline && isDownloadRequired, - onTap: () async { - var item = DownloadStub.fromItem( - type: DownloadItemType.song, - item: widget.item); - unawaited( - downloadsService.deleteDownload(stub: item)); - if (mounted) { - Navigator.pop(context); - } - }, - ), - ), - Visibility( - visible: !widget.isOffline && !isDownloadRequired, - child: ListTile( - leading: Icon( - Icons.file_download_outlined, - color: iconColor, - ), - title: Text( - AppLocalizations.of(context)!.downloadItem), - enabled: !widget.isOffline && !isDownloadRequired, - onTap: () async { - var item = DownloadStub.fromItem( - type: DownloadItemType.song, - item: widget.item); - await DownloadDialog.show(context, item, null); - if (mounted) { - Navigator.pop(context); - } - }, - ), + if (widget.showPlaybackControls) + SongMenuMask( + child: 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_empty, + onPressed: () async { + if (timerValue != null) { + await 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, + ), + ], + ); + }, + )), + SongMenuMask( + child: SliverPadding( + padding: const EdgeInsets.only(left: 8.0), + sliver: SliverList( + delegate: SliverChildListDelegate(menuEntries), ), - ]), - ), - ) - ], + ), + ) + ], + ), + ], + ); + }, + ); + }), + ]); + } + + List _menuEntries(BuildContext context, Color iconColor) { + final downloadsService = GetIt.instance(); + final downloadStatus = downloadsService.getStatus( + DownloadStub.fromItem(type: DownloadItemType.song, item: widget.item), + null); + + String? parentTooltip; + if (downloadStatus.isIncidental) { + var parent = downloadsService.getFirstRequiringItem(DownloadStub.fromItem( + type: DownloadItemType.song, item: widget.item)); + if (parent != null) { + var parentName = AppLocalizations.of(context)! + .itemTypeSubtitle(parent.baseItemType.name, parent.name); + parentTooltip = + AppLocalizations.of(context)!.incidentalDownloadTooltip(parentName); + } + } + + return [ + 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); // close menu + Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName, + arguments: widget.item.id); + }, + ), + ), + Visibility( + visible: _queueService.getQueue().nextUp.isNotEmpty, + child: ListTile( + leading: Icon( + TablerIcons.corner_right_down, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.playNext), + onTap: () async { + await _queueService.addNext( + items: [widget.item], + source: QueueItemSource( + type: QueueItemSourceType.nextUp, + name: const QueueItemSourceName( + type: QueueItemSourceNameType.nextUp), + id: widget.item.id)); + + if (!context.mounted) return; + + GlobalSnackbar.message( + (context) => + AppLocalizations.of(context)!.confirmPlayNext("track"), + isConfirmation: true); + Navigator.pop(context); + }, + ), + ), + ListTile( + leading: Icon( + TablerIcons.corner_right_down_double, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.addToNextUp), + onTap: () async { + await _queueService.addToNextUp( + items: [widget.item], + source: QueueItemSource( + type: QueueItemSourceType.nextUp, + name: const QueueItemSourceName( + type: QueueItemSourceNameType.nextUp), + id: widget.item.id)); + + if (!context.mounted) return; + + GlobalSnackbar.message( + (context) => + AppLocalizations.of(context)!.confirmAddToNextUp("track"), + isConfirmation: true); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon( + TablerIcons.playlist, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.addToQueue), + onTap: () async { + await _queueService.addToQueue( + items: [widget.item], + source: QueueItemSource( + type: QueueItemSourceType.queue, + name: const QueueItemSourceName( + type: QueueItemSourceNameType.queue), + id: widget.item.id)); + + if (!context.mounted) return; + + GlobalSnackbar.message( + (context) => AppLocalizations.of(context)!.addedToQueue, + isConfirmation: true); + Navigator.pop(context); + }, + ), + Visibility( + visible: widget.isInPlaylist && + widget.parentItem != null && + !widget.isOffline, + child: ListTile( + leading: Icon( + Icons.playlist_remove, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.removeFromPlaylistTitle), + enabled: widget.isInPlaylist && + widget.parentItem != null && + !widget.isOffline, + onTap: () async { + try { + await _jellyfinApiHelper.removeItemsFromPlaylist( + playlistId: widget.parentItem!.id, + entryIds: [widget.item.playlistItemId!]); + + if (!context.mounted) return; + + await _jellyfinApiHelper.getItems( + parentItem: + await _jellyfinApiHelper.getItemById(widget.item.parentId!), + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + ); + + if (!context.mounted) return; + + if (widget.onRemoveFromList != null) widget.onRemoveFromList!(); + + GlobalSnackbar.message( + (context) => + AppLocalizations.of(context)!.removedFromPlaylist, + isConfirmation: true); + Navigator.pop(context); + } catch (e) { + GlobalSnackbar.error(e); + } + }, + ), + ), + 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 (!context.mounted) return; + + GlobalSnackbar.message( + (context) => AppLocalizations.of(context)!.startingInstantMix, + isConfirmation: true); + Navigator.pop(context); + }, + ), + ), + Visibility( + visible: downloadStatus.isRequired, + child: ListTile( + leading: Icon( + Icons.delete_outlined, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.deleteItem), + enabled: downloadStatus.isRequired, + onTap: () async { + var item = DownloadStub.fromItem( + type: DownloadItemType.song, item: widget.item); + unawaited(downloadsService.deleteDownload(stub: item)); + if (mounted) { + Navigator.pop(context); + } + }, + ), + ), + Visibility( + visible: downloadStatus == DownloadItemStatus.notNeeded, + child: ListTile( + leading: Icon( + Icons.file_download_outlined, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.downloadItem), + enabled: !widget.isOffline && + downloadStatus == DownloadItemStatus.notNeeded, + onTap: () async { + var item = DownloadStub.fromItem( + type: DownloadItemType.song, item: widget.item); + await DownloadDialog.show(context, item, null); + if (context.mounted) { + Navigator.pop(context); + } + }, + ), + ), + Visibility( + visible: downloadStatus.isIncidental, + child: Tooltip( + message: parentTooltip ?? "Widget shouldn't be visible", + child: ListTile( + leading: Icon( + Icons.lock_outlined, + color: iconColor, + ), + title: Text(AppLocalizations.of(context)!.lockDownload), + enabled: !widget.isOffline && downloadStatus.isIncidental, + onTap: () async { + var item = DownloadStub.fromItem( + type: DownloadItemType.song, item: widget.item); + await DownloadDialog.show(context, item, null); + if (context.mounted) { + Navigator.pop(context); + } + }, + ), + ), + ), + ListTile( + enabled: !widget.isOffline, + leading: widget.item.userData!.isFavorite + ? Icon( + Icons.favorite, + color: + widget.isOffline ? iconColor.withOpacity(0.3) : iconColor, + ) + : Icon( + Icons.favorite_border, + color: + widget.isOffline ? iconColor.withOpacity(0.3) : iconColor, ), - ], - ); + title: Text(widget.item.userData!.isFavorite + ? AppLocalizations.of(context)!.removeFavourite + : AppLocalizations.of(context)!.addFavourite), + onTap: () async { + await toggleFavorite(); + if (context.mounted) 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; + try { + if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsService = GetIt.instance(); + album = (await downloadsService.getCollectionInfo( + id: widget.item.albumId!))! + .baseItem!; + } else { + album = + await _jellyfinApiHelper.getItemById(widget.item.albumId!); + } + } catch (e) { + GlobalSnackbar.error(e); + return; + } + if (context.mounted) { + Navigator.pop(context); + await 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; + try { + if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsService = GetIt.instance(); + artist = (await downloadsService.getCollectionInfo( + id: widget.item.artistItems!.first.id))! + .baseItem!; + } else { + artist = await _jellyfinApiHelper + .getItemById(widget.item.artistItems!.first.id); + } + } catch (e) { + GlobalSnackbar.error(e); + return; + } + if (context.mounted) { + Navigator.pop(context); + await 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; + try { + if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsService = GetIt.instance(); + genre = (await downloadsService.getCollectionInfo( + id: widget.item.genreItems!.first.id))! + .baseItem!; + } else { + genre = await _jellyfinApiHelper + .getItemById(widget.item.genreItems!.first.id); + } + } catch (e) { + GlobalSnackbar.error(e); + return; + } + if (context.mounted) { + Navigator.pop(context); + await Navigator.of(context) + .pushNamed(ArtistScreen.routeName, arguments: genre); + } + }, + ), + ), + ]; } } @@ -749,7 +780,7 @@ class SongMenuSliverAppBar extends SliverPersistentHeaderDelegate { double get maxExtent => 150; @override - double get minExtent => 100; + double get minExtent => 150; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => @@ -774,8 +805,6 @@ class _SongInfo extends ConsumerStatefulWidget { } class _SongInfoState extends ConsumerState<_SongInfo> { - final _queueService = GetIt.instance(); - VoidCallback? onDispose; bool waitingForTheme = false; @@ -980,3 +1009,41 @@ class PlaybackAction extends StatelessWidget { ); } } + +class SongMenuMask extends SingleChildRenderObjectWidget { + const SongMenuMask({ + super.key, + super.child, + }); + + @override + RenderSongMenuMask createRenderObject(BuildContext context) { + return RenderSongMenuMask(); + } +} + +class RenderSongMenuMask extends RenderProxySliver { + @override + ShaderMaskLayer? get layer => super.layer as ShaderMaskLayer?; + + @override + bool get alwaysNeedsCompositing => child != null; + + @override + void paint(PaintingContext context, Offset offset) { + if (child != null) { + layer ??= ShaderMaskLayer( + shader: const LinearGradient(colors: [ + Color.fromARGB(0, 255, 255, 255), + Color.fromARGB(255, 255, 255, 255) + ], begin: Alignment.topCenter, end: Alignment.bottomCenter) + .createShader(const Rect.fromLTWH(0, 135, 0, 10)), + blendMode: BlendMode.modulate, + maskRect: const Rect.fromLTWH(0, 0, 99999, 150)); + + context.pushLayer(layer!, super.paint, offset); + } else { + layer = null; + } + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b4b3ee019..8d45860c2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1256,5 +1256,9 @@ "enableVibration": "Enable vibration", "@enableVibration": {}, "enableVibrationSubtitle": "Whether to enable vibration.", - "@enableVibrationSubtitle": {} + "@enableVibrationSubtitle": {}, + "lockDownload": "Keep on Device", + "@lockDownload": { + "description": "Text shown on button to require an item which is already downloaded by a parent collection, preventing it from being deleted if the parent is removed." + } } diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index 60b2a533e..f0eb54e29 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -1092,16 +1092,17 @@ enum DownloadItemState { /// The status of a download, as used to determine download button state. /// Obtain via downloadsService statusProvider. enum DownloadItemStatus { - notNeeded(false, false), - incidental(false, false), - incidentalOutdated(false, true), - required(true, false), - requiredOutdated(true, true); + notNeeded(false, false, false), + incidental(false, false, true), + incidentalOutdated(false, true, true), + required(true, false, false), + requiredOutdated(true, true, false); - const DownloadItemStatus(this.isRequired, this.outdated); + const DownloadItemStatus(this.isRequired, this.outdated, this.isIncidental); final bool isRequired; final bool outdated; + final bool isIncidental; } /// The type of a BaseItemDto as determined from its type field.