From 4c2f533eef4b91dafed6352f746dd1574a7bd8ad Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:50:00 +0200 Subject: [PATCH] refactor(music): share the playlist play/download/delete flows between the context menu and the detail screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audio-playlist play flow, the playlist download flow (including its inline MediaItem synthesis), the delete-with-confirm flow, and the fetch-artist-then-navigate flow each existed twice — once in MediaContextMenu and once in the playlist/album/now-playing screens, with 'Match PlaylistDetailScreen' comments standing in for shared code. They now live in music_navigation.dart and download_utils.dart with per-caller strings preserved; the menu's collection/playlist download handlers fold into one parameterized handler, its duplicated sync/download menu tree is built once, and the generic picker dialogs move to collection_picker_dialog.dart (pure move). --- lib/screens/music/album_detail_screen.dart | 9 +- lib/screens/music/now_playing_screen.dart | 14 +- .../playlist/playlist_detail_screen.dart | 95 +-- lib/utils/download_utils.dart | 103 ++++ lib/utils/music_navigation.dart | 43 ++ lib/widgets/collection_picker_dialog.dart | 279 +++++++++ lib/widgets/media_context_menu.dart | 543 ++++-------------- 7 files changed, 549 insertions(+), 537 deletions(-) create mode 100644 lib/widgets/collection_picker_dialog.dart diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart index 60e48104c..4501850b6 100644 --- a/lib/screens/music/album_detail_screen.dart +++ b/lib/screens/music/album_detail_screen.dart @@ -112,14 +112,7 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen _openArtist() async { final parentId = widget.album.parentId; if (parentId == null) return; - MediaItem? artist; - try { - artist = await mediaClient.fetchItem(parentId); - } catch (e) { - appLogger.w('Failed to fetch artist $parentId for album ${widget.album.id}', error: e); - } - if (artist == null || !mounted) return; - await navigateToArtist(context, artist); + await openArtistById(context, mediaClient, parentId); } void _showOverflowMenuAt(BuildContext buttonContext) { diff --git a/lib/screens/music/now_playing_screen.dart b/lib/screens/music/now_playing_screen.dart index e2d3c0900..aa31c4ca1 100644 --- a/lib/screens/music/now_playing_screen.dart +++ b/lib/screens/music/now_playing_screen.dart @@ -25,7 +25,6 @@ import '../../services/device_performance.dart'; import '../../services/music/music_playback_service.dart'; import '../../theme/mono_motion.dart'; import '../../theme/mono_tokens.dart'; -import '../../utils/app_logger.dart'; import '../../utils/formatters.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/media_image_helper.dart'; @@ -179,20 +178,13 @@ class _NowPlayingScreenState extends State _dismissSettle.forward(from: 0); } - /// Artist line tap — the track's grandparent is the artist. Mirrors the - /// album screen's artist link (fetch, then navigate; soft-fail). + /// Artist line tap — the track's grandparent is the artist. Shares the + /// album screen's fetch-then-navigate flow via [openArtistById]. Future _openArtist(MediaItem track) async { final artistId = track.grandparentId; final client = context.getMediaClientForItemOrNull(track); if (artistId == null || client == null) return; - MediaItem? artist; - try { - artist = await client.fetchItem(artistId); - } catch (e) { - appLogger.w('Failed to fetch artist $artistId for track ${track.id}', error: e); - } - if (artist == null || !mounted) return; - await navigateToArtist(context, artist); + await openArtistById(context, client, artistId); } Future _showSleepTimerSheet() async { diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 18a04b3b4..7a881e4d0 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -6,13 +6,10 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_action_bar.dart'; import '../../media/library_query.dart'; import '../../media/media_item.dart'; -import '../../media/media_kind.dart'; import '../../media/media_playlist.dart'; import '../../services/media_list_playback_launcher.dart'; -import '../../services/music/music_playback_service.dart'; import '../../services/playlist_items_loader.dart'; import '../../utils/app_logger.dart'; -import '../../utils/content_utils.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/continuation_pagination_coordinator.dart'; import '../../utils/music_navigation.dart'; @@ -26,7 +23,6 @@ import 'playlist_item_card.dart'; import '../../i18n/strings.g.dart'; import '../../providers/download_provider.dart'; import '../../utils/platform_detector.dart'; -import '../../utils/dialogs.dart'; import '../../utils/download_utils.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; @@ -76,9 +72,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen widget.playlist.playlistType == 'audio'; - MusicPlayContext get _musicPlayContext => - MusicPlayContext(id: widget.playlist.id, title: widget.playlist.title, kind: MusicPlayContextKind.playlist); - @override Future playItems() => _isAudioPlaylist ? _playAudioPlaylist(shuffle: false) : super.playItems(); @@ -93,14 +86,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _isPlaylistFullyLoaded ? items : await fetchAllPlaylistItems(mediaClient, widget.playlist.id), - playContext: _musicPlayContext, + client: mediaClient, + playlist: widget.playlist, + shuffle: shuffle, + startTrack: startTrack, + preloadedItems: _isPlaylistFullyLoaded ? items : null, onError: (e, stackTrace) => showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title)), - startTrack: startTrack, - shuffle: shuffle, ); } @@ -141,18 +135,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen MediaItem( - id: widget.playlist.id, - backend: widget.playlist.backend, - kind: MediaKind.playlist, - title: widget.playlist.title, - thumbPath: widget.playlist.thumbPath, - serverId: widget.playlist.serverId ?? mediaClient.serverId, - serverName: widget.playlist.serverName, - ); - // Focus management for regular (non-smart) reorderable lists final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list'); final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry'); @@ -298,58 +280,21 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _downloadPlaylist() async { - final downloadProvider = Provider.of(context, listen: false); + Future _downloadPlaylist() => downloadPlaylist( + context, + client: mediaClient, + downloadProvider: Provider.of(context, listen: false), + playlist: widget.playlist, + ); - try { - final allItems = await fetchAllPlaylistItems(mediaClient, widget.playlist.id); - if (!mounted) return; - final result = await showListDownloadOptionsAndQueue( - context, - rootMetadata: _playlistAsMetadata(), - targetType: ContentTypes.playlist, - items: allItems, - client: mediaClient, - downloadProvider: downloadProvider, - ); - if (result == null || !mounted) return; - - showSuccessSnackBar(context, result.toSnackBarMessage()); - } on CellularDownloadBlockedException { - if (mounted) { - showErrorSnackBar(context, t.settings.cellularDownloadBlocked); - } - } catch (e) { - if (mounted) { - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } - } - } - - Future _deletePlaylist() async { - final confirmed = await showDeleteConfirmation( - context, - title: t.playlists.deleteConfirm, - message: t.playlists.deleteMessage(name: widget.playlist.title), - ); - - if (!confirmed || !mounted) return; - - bool success = false; - try { - success = await mediaClient.deletePlaylist(widget.playlist); - } catch (e) { - appLogger.e('Failed to delete playlist', error: e); - } - - if (!mounted) return; - if (success) { - showSuccessSnackBar(context, t.playlists.deleted); - Navigator.pop(context, true); // Signal the parent list to refresh. - } else { - showErrorSnackBar(context, t.playlists.errorDeleting); - } - } + Future _deletePlaylist() => deletePlaylistWithConfirm( + context, + client: mediaClient, + playlist: widget.playlist, + confirmTitle: t.playlists.deleteConfirm, + // Signal the parent list to refresh. + onDeleted: () => Navigator.pop(context, true), + ); /// The item that should sit immediately before the moved item at [newIndex], /// or null when moving to position 0. Pushes per-backend id-extraction down diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index aff85f826..20144fb31 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -7,10 +7,12 @@ import '../focus/focusable_action_bar.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; +import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; import '../database/app_database.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; +import '../services/playlist_items_loader.dart'; import '../services/settings_service.dart'; import '../services/sync_rule_executor.dart'; import '../widgets/background_download_warning_banner.dart'; @@ -279,6 +281,72 @@ Future showListDownloadOptionsAndQueue( ); } +/// Fetch a list's items with [fetchItems], then run +/// [showListDownloadOptionsAndQueue] and surface the outcome: a success +/// snackbar for a queued download, dedicated copy for cellular-blocked +/// downloads, and a generic error snackbar otherwise. The fetch → dialog → +/// snackbar shape shared by the detail screens and the context menu. +Future fetchAndQueueListDownload( + BuildContext context, { + required MediaServerClient client, + required DownloadProvider downloadProvider, + required Future> Function() fetchItems, + required MediaItem rootMetadata, + required String targetType, +}) async { + try { + final items = await fetchItems(); + if (!context.mounted) return; + + final result = await showListDownloadOptionsAndQueue( + context, + rootMetadata: rootMetadata, + targetType: targetType, + items: items, + client: client, + downloadProvider: downloadProvider, + ); + if (result == null || !context.mounted) return; + + showSuccessSnackBar(context, result.toSnackBarMessage()); + } on CellularDownloadBlockedException { + if (context.mounted) { + showErrorSnackBar(context, t.settings.cellularDownloadBlocked); + } + } catch (e) { + appLogger.e('Failed to queue $targetType download', error: e); + if (context.mounted) { + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } + } +} + +/// Download [playlist]: page through its items via the backend-neutral +/// interface (so Jellyfin playlists download too), synthesise the [MediaItem] +/// view the download pipeline expects, and run [fetchAndQueueListDownload]. +/// Shared by the playlist detail screen and the context menu. +Future downloadPlaylist( + BuildContext context, { + required MediaServerClient client, + required DownloadProvider downloadProvider, + required MediaPlaylist playlist, +}) => fetchAndQueueListDownload( + context, + client: client, + downloadProvider: downloadProvider, + fetchItems: () => fetchAllPlaylistItems(client, playlist.id), + rootMetadata: MediaItem( + id: playlist.id, + backend: playlist.backend, + kind: MediaKind.playlist, + title: playlist.title, + thumbPath: playlist.thumbPath, + serverId: playlist.serverId ?? client.serverId, + serverName: playlist.serverName, + ), + targetType: ContentTypes.playlist, +); + /// The all/unwatched option rows, shared by the pickers that differ only in /// how they spell those two values. List<({IconData? icon, String label, T value})> _filterOptions(T all, T unwatched) => [ @@ -565,3 +633,38 @@ List buildSyncRuleActions( ), ]; } + +/// Confirm-and-delete flow for a playlist: confirmation dialog (titled by the +/// caller — the detail screen and the context menu use different strings), +/// [MediaServerClient.deletePlaylist], then success/error snackbars. Runs +/// [onDeleted] only after a confirmed successful delete; the detail screen +/// pops itself, the context menu triggers a list refresh. +Future deletePlaylistWithConfirm( + BuildContext context, { + required MediaServerClient client, + required MediaPlaylist playlist, + required String confirmTitle, + required VoidCallback onDeleted, +}) async { + final confirmed = await showDeleteConfirmation( + context, + title: confirmTitle, + message: t.playlists.deleteMessage(name: playlist.title), + ); + if (!confirmed || !context.mounted) return; + + bool success = false; + try { + success = await client.deletePlaylist(playlist); + } catch (e) { + appLogger.e('Failed to delete playlist', error: e); + } + + if (!context.mounted) return; + if (success) { + showSuccessSnackBar(context, t.playlists.deleted); + onDeleted(); + } else { + showErrorSnackBar(context, t.playlists.errorDeleting); + } +} diff --git a/lib/utils/music_navigation.dart b/lib/utils/music_navigation.dart index 3558da41e..d2e428053 100644 --- a/lib/utils/music_navigation.dart +++ b/lib/utils/music_navigation.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../media/media_item.dart'; +import '../media/media_playlist.dart'; +import '../media/media_server_client.dart'; import '../navigation/profile_navigation_scope.dart'; import '../screens/music/album_detail_screen.dart'; import '../screens/music/artist_detail_screen.dart'; import '../screens/music/now_playing_screen.dart'; import '../services/device_performance.dart'; import '../services/music/music_playback_service.dart'; +import '../services/playlist_items_loader.dart'; import '../theme/mono_motion.dart'; import '../theme/mono_tokens.dart'; import 'app_logger.dart'; @@ -31,6 +34,20 @@ Future navigateToArtist(BuildContext context, MediaItem artist) async { await _contentNavigatorOf(context).push(MaterialPageRoute(builder: (context) => ArtistDetailScreen(artist: artist))); } +/// Fetch the artist [artistId] with [client], then push its detail screen on +/// the profile navigator. Soft-fails: a failed fetch is logged and skipped, +/// as is navigation after [context] unmounts during the fetch. +Future openArtistById(BuildContext context, MediaServerClient client, String artistId) async { + MediaItem? artist; + try { + artist = await client.fetchItem(artistId); + } catch (e) { + appLogger.w('Failed to fetch artist $artistId', error: e); + } + if (artist == null || !context.mounted) return; + await navigateToArtist(context, artist); +} + /// Push the album detail screen for [album] on the profile navigator. Future navigateToAlbum(BuildContext context, MediaItem album) async { await _contentNavigatorOf(context).push(MaterialPageRoute(builder: (context) => AlbumDetailScreen(album: album))); @@ -145,6 +162,32 @@ Future playFetchedTracks( await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle); } +/// Play an audio [playlist] through the music session: hand its tracks to +/// [playFetchedTracks] under a playlist play-context. Callers that already +/// hold the full track list pass it as [preloadedItems] to skip the server +/// round-trip; [onError] and [onEmpty] carry each caller's own snackbar +/// strings. Shared by the playlist detail screen and the media context menu. +Future playAudioPlaylist( + BuildContext context, { + required MediaServerClient client, + required MediaPlaylist playlist, + bool shuffle = false, + MediaItem? startTrack, + List? preloadedItems, + void Function(Object error, StackTrace stackTrace)? onError, + VoidCallback? onEmpty, +}) { + return playFetchedTracks( + context, + fetch: () async => preloadedItems ?? await fetchAllPlaylistItems(client, playlist.id), + playContext: MusicPlayContext(id: playlist.id, title: playlist.title, kind: MusicPlayContextKind.playlist), + onError: onError, + onEmpty: onEmpty, + startTrack: startTrack, + shuffle: shuffle, + ); +} + /// Play [track] within its album queue: fetch the album's tracks and start /// at [track]. Falls back to single-track playback when the track has no /// album, isn't found in it, or the album fetch fails. diff --git a/lib/widgets/collection_picker_dialog.dart b/lib/widgets/collection_picker_dialog.dart new file mode 100644 index 000000000..cc83727b9 --- /dev/null +++ b/lib/widgets/collection_picker_dialog.dart @@ -0,0 +1,279 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../focus/focusable_text_field.dart'; +import '../focus/key_event_utils.dart'; +import '../i18n/strings.g.dart'; +import '../media/library_query.dart'; +import '../media/media_item.dart'; +import '../media/media_playlist.dart'; +import '../media/media_server_client.dart'; +import '../utils/media_server_http_client.dart'; +import 'app_icon.dart'; +import 'dialog_action_button.dart'; +import 'focusable_list_tile.dart'; +import 'pill_input_decoration.dart'; + +typedef _PickerPageLoader = Future> Function(int start, int size, AbortController abort); + +typedef _PickerItemBuilder = Widget Function(BuildContext context, T item); + +/// Shared loading, filtering, and TV focus shell for collection-style pickers. +class _PickerDialogScaffold extends StatefulWidget { + final String title; + final String searchHint; + final String emptyMessage; + final _PickerPageLoader loadPage; + final String Function(T item) itemTitle; + final _PickerItemBuilder itemBuilder; + + const _PickerDialogScaffold({ + required this.title, + required this.searchHint, + required this.emptyMessage, + required this.loadPage, + required this.itemTitle, + required this.itemBuilder, + }); + + @override + State<_PickerDialogScaffold> createState() => _PickerDialogScaffoldState(); +} + +class _PickerDialogScaffoldState extends State<_PickerDialogScaffold> { + static const int _pageSize = 100; + static const int _filterThreshold = 10; + + final _filterController = TextEditingController(); + final _filterFocusNode = FocusNode(debugLabel: 'PickerFilter'); + final _firstItemFocusNode = FocusNode(debugLabel: 'PickerFirstItem'); + final _abortController = AbortController(); + final _scrollController = ScrollController(); + final List _items = []; + List _filteredItems = []; + bool _isLoading = false; + bool _initialFocusRequested = false; + String? _errorMessage; + int? _totalCount; + + bool get _hasMore => _totalCount == null || _items.length < _totalCount!; + bool get _showFilter => _items.length >= _filterThreshold; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + unawaited(_loadNextPage()); + } + + @override + void dispose() { + _abortController.abort(); + _scrollController.dispose(); + _filterController.dispose(); + _filterFocusNode.dispose(); + _firstItemFocusNode.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients || !_hasMore || _isLoading) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 240) { + unawaited(_loadNextPage()); + } + } + + Future _loadNextPage() async { + if (_isLoading || !_hasMore) return; + setState(() { + _isLoading = true; + _errorMessage = null; + }); + try { + while (mounted && _hasMore) { + final page = await widget.loadPage(_items.length, _pageSize, _abortController); + if (!mounted) return; + setState(() { + _items.addAll(page.items); + _totalCount = page.totalCount; + _applyFilter(_filterController.text); + }); + if (_filterController.text.isEmpty || page.items.isEmpty) break; + } + if (!mounted) return; + setState(() => _isLoading = false); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + _requestInitialFocus(); + } + + void _requestInitialFocus() { + if (_initialFocusRequested) return; + _initialFocusRequested = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + (_showFilter ? _filterFocusNode : _firstItemFocusNode).requestFocus(); + }); + } + + void _onFilterChanged(String query) { + setState(() => _applyFilter(query)); + if (query.isNotEmpty && _hasMore) { + unawaited(_loadNextPage()); + } + } + + void _applyFilter(String query) { + final lower = query.toLowerCase(); + _filteredItems = lower.isEmpty + ? List.of(_items) + : _items.where((item) => widget.itemTitle(item).toLowerCase().contains(lower)).toList(); + } + + @override + Widget build(BuildContext context) { + final showStatus = _hasMore || _isLoading || _errorMessage != null || _filteredItems.isEmpty; + return Focus( + onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), + child: AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: .min, + children: [ + if (_showFilter) ...[ + FocusableTextField( + controller: _filterController, + focusNode: _filterFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, + onNavigateDown: _firstItemFocusNode.requestFocus, + decoration: pillInputDecoration( + context, + hintText: widget.searchHint, + prefixIcon: const AppIcon(Symbols.search_rounded, size: 20), + ), + onChanged: _onFilterChanged, + ), + const SizedBox(height: 8), + ], + Flexible( + child: ListView.builder( + controller: _scrollController, + shrinkWrap: true, + itemCount: _filteredItems.length + 1 + (showStatus ? 1 : 0), + itemBuilder: (context, index) { + if (index == 0) { + return FocusableListTile( + focusNode: _firstItemFocusNode, + leading: const AppIcon(Symbols.add_rounded, fill: 1), + title: Text(t.common.createNew), + onTap: () => Navigator.pop(context, '_create_new'), + ); + } + + if (index <= _filteredItems.length) { + return widget.itemBuilder(context, _filteredItems[index - 1]); + } + + if (_errorMessage != null) { + return FocusableListTile( + leading: const AppIcon(Symbols.error_rounded, fill: 1), + title: Text(t.messages.errorLoading(error: _errorMessage!)), + onTap: _loadNextPage, + ); + } + if (_hasMore || _isLoading) { + if (_hasMore && !_isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_loadNextPage()); + }); + } + return const Padding( + padding: .all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + return Padding( + padding: const .all(16), + child: Text(widget.emptyMessage, textAlign: TextAlign.center), + ); + }, + ), + ), + ], + ), + ), + actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)], + ), + ); + } +} + +/// Dialog to select a playlist or create a new one. +class PlaylistSelectionDialog extends StatelessWidget { + final MediaServerClient client; + + const PlaylistSelectionDialog({super.key, required this.client}); + + @override + Widget build(BuildContext context) { + return _PickerDialogScaffold( + title: t.playlists.selectPlaylist, + searchHint: t.playlists.searchPlaylists, + emptyMessage: t.playlists.noPlaylists, + loadPage: (start, size, abort) => + client.fetchPlaylistsPage(playlistType: 'video', smart: false, start: start, size: size, abort: abort), + itemTitle: (playlist) => playlist.title, + itemBuilder: (context, playlist) { + final leafCount = playlist.leafCount; + final subtitleText = leafCount == 1 ? t.playlists.oneItem : t.playlists.itemCount(count: leafCount ?? 0); + return FocusableListTile( + leading: playlist.smart + ? const AppIcon(Symbols.auto_awesome_rounded, fill: 1) + : const AppIcon(Symbols.playlist_play_rounded, fill: 1), + title: Text(playlist.title), + subtitle: playlist.leafCount != null ? Text(subtitleText) : null, + onTap: playlist.smart + ? null // Disable smart playlists + : () => Navigator.pop(context, playlist.id), + enabled: !playlist.smart, + ); + }, + ); + } +} + +/// Dialog to select a collection or create a new one +class CollectionSelectionDialog extends StatelessWidget { + final MediaServerClient client; + final String libraryId; + + const CollectionSelectionDialog({super.key, required this.client, required this.libraryId}); + + @override + Widget build(BuildContext context) { + return _PickerDialogScaffold( + title: t.collections.selectCollection, + searchHint: t.collections.searchCollections, + emptyMessage: t.libraries.noCollections, + loadPage: (start, size, abort) => client.fetchCollectionsPage(libraryId, start: start, size: size, abort: abort), + itemTitle: (collection) => collection.title ?? '', + itemBuilder: (context, collection) => FocusableListTile( + leading: const AppIcon(Symbols.collections_rounded, fill: 1), + title: Text(collection.title ?? ''), + subtitle: collection.childCount != null ? Text(t.playlists.itemCount(count: collection.childCount!)) : null, + onTap: () => Navigator.pop(context, collection.id), + ), + ); + } +} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 7e48a1ea7..51aceee5d 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,7 +1,6 @@ import 'dart:async'; import '../media/ids.dart'; import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -11,7 +10,6 @@ import '../media/media_item.dart'; import '../media/media_item_labels.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; -import '../media/library_query.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; import '../metadata_edit/metadata_edit_adapters.dart'; @@ -41,15 +39,12 @@ import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/library_refresh_notifier.dart'; import '../utils/media_navigation_helper.dart'; -import '../utils/media_server_http_client.dart'; import '../utils/music_navigation.dart'; import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../utils/dialogs.dart'; import '../services/external_player_service.dart'; -import 'dialog_action_button.dart'; -import '../focus/focusable_text_field.dart'; -import '../focus/key_event_utils.dart'; +import 'collection_picker_dialog.dart'; import '../screens/plex_match_screen.dart'; import '../screens/media_detail_screen.dart'; import '../screens/metadata_edit_screen.dart'; @@ -60,8 +55,6 @@ import '../utils/video_player_navigation.dart'; import '../utils/deletion_notifier.dart'; import '../widgets/app_menu.dart'; import '../widgets/file_info_bottom_sheet.dart'; -import 'pill_input_decoration.dart'; -import '../widgets/focusable_list_tile.dart'; import '../widgets/overlay_sheet.dart'; import 'watchlist_source_chooser.dart'; import '../widgets/rating_bottom_sheet.dart'; @@ -76,6 +69,37 @@ class _MenuAction { _MenuAction({required this.value, required this.icon, required this.label, this.destructive = false}); } +/// The sync-rule / download entry set shared by the list (playlist/collection) +/// and single-item menu branches: an existing rule offers manage/remove (plus +/// download deletion when files exist), an existing download alone offers +/// deletion, and otherwise the plain download entry — [downloadValue] names +/// the action the calling branch dispatches on. +List<_MenuAction> _syncDownloadMenuActions({ + required bool hasSyncRule, + required bool hasAnyDownload, + required String downloadValue, +}) { + _MenuAction deleteDownload() => _MenuAction( + value: 'delete_download', + icon: Symbols.delete_rounded, + label: t.downloads.deleteDownload, + destructive: true, + ); + if (hasSyncRule) { + return [ + _MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule), + _MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule), + if (hasAnyDownload) deleteDownload(), + ]; + } + return [ + if (hasAnyDownload) + deleteDownload() + else + _MenuAction(value: downloadValue, icon: Symbols.download_rounded, label: t.downloads.downloadNow), + ]; +} + bool isAdminActionAllowedForMediaItem({ required bool isOwnerOrAdmin, required MediaBackend? itemBackend, @@ -390,23 +414,13 @@ class MediaContextMenuState extends State { final isDownloadablePlaylist = isPlaylist && (playlist.playlistType == 'video' || playlist.playlistType == 'audio'); if ((isDownloadablePlaylist || isCollection) && !PlatformDetector.isAppleTV()) { - final hasRule = Provider.of(context, listen: false).hasSyncRule(_itemSyncRuleKey(context)); - if (hasRule) { - menuActions.add( - _MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule), - ); - menuActions.add( - _MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule), - ); - } else { - menuActions.add( - _MenuAction( - value: isPlaylist ? 'download_playlist' : 'download_collection', - icon: Symbols.download_rounded, - label: t.downloads.downloadNow, - ), - ); - } + menuActions.addAll( + _syncDownloadMenuActions( + hasSyncRule: Provider.of(context, listen: false).hasSyncRule(_itemSyncRuleKey(context)), + hasAnyDownload: false, + downloadValue: isPlaylist ? 'download_playlist' : 'download_collection', + ), + ); } menuActions.add( @@ -619,41 +633,13 @@ class MediaContextMenuState extends State { mediaKind == MediaKind.album || mediaKind == MediaKind.track)) { final downloadProvider = Provider.of(context, listen: false); - final globalKey = mediaItem.globalKey; - final hasSyncRule = downloadProvider.hasSyncRule(_itemSyncRuleKey(context)); - final hasAnyDownload = downloadProvider.getProgress(globalKey) != null; - - if (hasSyncRule) { - menuActions.add( - _MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule), - ); - menuActions.add( - _MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule), - ); - if (hasAnyDownload) { - menuActions.add( - _MenuAction( - value: 'delete_download', - icon: Symbols.delete_rounded, - label: t.downloads.deleteDownload, - destructive: true, - ), - ); - } - } else if (hasAnyDownload) { - menuActions.add( - _MenuAction( - value: 'delete_download', - icon: Symbols.delete_rounded, - label: t.downloads.deleteDownload, - destructive: true, - ), - ); - } else { - menuActions.add( - _MenuAction(value: 'download', icon: Symbols.download_rounded, label: t.downloads.downloadNow), - ); - } + menuActions.addAll( + _syncDownloadMenuActions( + hasSyncRule: downloadProvider.hasSyncRule(_itemSyncRuleKey(context)), + hasAnyDownload: downloadProvider.getProgress(mediaItem.globalKey) != null, + downloadValue: 'download', + ), + ); } if (showWatchlistEntry) { @@ -912,11 +898,11 @@ class MediaContextMenuState extends State { break; case 'download_playlist': - await _handleDownloadPlaylist(context); + await _handleDownloadList(context, isPlaylist: true); break; case 'download_collection': - await _handleDownloadCollection(context); + await _handleDownloadList(context, isPlaylist: false); break; case 'download': @@ -1332,7 +1318,7 @@ class MediaContextMenuState extends State { final result = await showScopedDialog( context: context, - builder: (context) => _PlaylistSelectionDialog(client: client), + builder: (context) => PlaylistSelectionDialog(client: client), ); if (result == null || !context.mounted) return; @@ -1408,7 +1394,7 @@ class MediaContextMenuState extends State { final result = await showScopedDialog( context: context, - builder: (context) => _CollectionSelectionDialog(client: client, libraryId: resolvedLibraryId), + builder: (context) => CollectionSelectionDialog(client: client, libraryId: resolvedLibraryId), ); if (result == null || !context.mounted) return; @@ -1583,8 +1569,18 @@ class MediaContextMenuState extends State { /// in-memory queue locally. Future _launchCollectionOrPlaylist(BuildContext context, {required bool shuffle}) async { final playlist = _playlist; - if (playlist?.playlistType == 'audio') { - await _launchAudioPlaylist(context, playlist!, shuffle: shuffle); + if (playlist != null && playlist.playlistType == 'audio') { + await playAudioPlaylist( + context, + client: _getMediaClientForItem(), + playlist: playlist, + shuffle: shuffle, + onError: (e, st) { + appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + }, + onEmpty: () => showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems), + ); return; } @@ -1593,65 +1589,43 @@ class MediaContextMenuState extends State { await launcher.launchFromCollectionOrPlaylist(item: widget.item, shuffle: shuffle); } - Future _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async { - // Match PlaylistDetailScreen: fail the availability gate before paying - // for a full playlist fetch, then hand the tracks to the music session. - await playFetchedTracks( - context, - fetch: () => fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id), - playContext: MusicPlayContext(id: playlist.id, title: playlist.title, kind: MusicPlayContextKind.playlist), - onError: (e, st) { - appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - }, - onEmpty: () => showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems), - shuffle: shuffle, - ); - } - /// Handle delete action for collections and playlists - Future _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async { + Future _handleDelete(BuildContext context, bool _, bool isPlaylist) async { final client = _getMediaClientForItem(); - final itemTitle = _itemDisplayTitle(); - final itemTypeLabel = isCollection ? t.collections.collection : t.playlists.playlist; + if (isPlaylist) { + await deletePlaylistWithConfirm( + context, + client: client, + playlist: _playlist!, + confirmTitle: t.playlists.delete, + onDeleted: _notifyListRefresh, + ); + return; + } - // Show confirmation dialog final confirmed = await showDeleteConfirmation( context, - title: isCollection ? t.collections.deleteCollection : t.playlists.delete, - message: isCollection - ? t.collections.deleteConfirm(title: itemTitle) - : t.playlists.deleteMessage(name: itemTitle), + title: t.collections.deleteCollection, + message: t.collections.deleteConfirm(title: _itemDisplayTitle()), ); - if (!confirmed || !context.mounted) return; try { - bool success = false; - - if (isCollection) { - success = await client.deleteCollection(_mediaItem!); - } else if (isPlaylist) { - success = await client.deletePlaylist(_playlist!); - } - + final success = await client.deleteCollection(_mediaItem!); if (context.mounted) { if (success) { - showSuccessSnackBar(context, isCollection ? t.collections.deleted : t.playlists.deleted); + showSuccessSnackBar(context, t.collections.deleted); // Trigger list refresh _notifyListRefresh(); } else { - showErrorSnackBar(context, isCollection ? t.collections.deleteFailed : t.playlists.errorDeleting); + showErrorSnackBar(context, t.collections.deleteFailed); } } } catch (e) { - appLogger.e('Failed to delete $itemTypeLabel', error: e); + appLogger.e('Failed to delete collection', error: e); if (context.mounted) { - showErrorSnackBar( - context, - isCollection ? t.collections.deleteFailedWithError(error: e.toString()) : t.playlists.errorDeleting, - ); + showErrorSnackBar(context, t.collections.deleteFailedWithError(error: e.toString())); } } } @@ -1691,88 +1665,33 @@ class MediaContextMenuState extends State { ); } - /// Handle download collection action — opens the same sync/one-time dialog - /// as playlists, wired to [showListDownloadOptionsAndQueue]. - Future _handleDownloadCollection(BuildContext context) async { - final collection = _mediaItem!; + /// One dialog-driven download flow for both list kinds — the sync/one-time + /// dialog wired to [showListDownloadOptionsAndQueue] via + /// [fetchAndQueueListDownload]. Playlists synthesise their root metadata + /// inside [downloadPlaylist]; collections pass the item itself. + Future _handleDownloadList(BuildContext context, {required bool isPlaylist}) async { final downloadProvider = Provider.of(context, listen: false); final client = _getMediaClientForItem(); - try { - final items = await fetchAllCollectionItemsPaged( + if (isPlaylist) { + await downloadPlaylist(context, client: client, downloadProvider: downloadProvider, playlist: _playlist!); + return; + } + + final collection = _mediaItem!; + await fetchAndQueueListDownload( + context, + client: client, + downloadProvider: downloadProvider, + fetchItems: () => fetchAllCollectionItemsPaged( client, collection.id, libraryId: collection.libraryId, libraryTitle: collection.libraryTitle, - ); - if (!context.mounted) return; - - final result = await showListDownloadOptionsAndQueue( - context, - rootMetadata: collection, - targetType: ContentTypes.collection, - items: items, - client: client, - downloadProvider: downloadProvider, - ); - if (result == null || !context.mounted) return; - - showSuccessSnackBar(context, result.toSnackBarMessage()); - } on CellularDownloadBlockedException { - if (context.mounted) { - showErrorSnackBar(context, t.settings.cellularDownloadBlocked); - } - } catch (e) { - appLogger.e('Failed to queue collection download', error: e); - if (context.mounted) { - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } - } - } - - /// Handle download playlist action - Future _handleDownloadPlaylist(BuildContext context) async { - final playlist = _playlist!; - final downloadProvider = Provider.of(context, listen: false); - final client = _getMediaClientForItem(); - - try { - // Page through the playlist via the neutral interface so Jellyfin - // playlists download too. - final items = await fetchAllPlaylistItems(client, playlist.id); - if (!context.mounted) return; - - final playlistMetadata = MediaItem( - id: playlist.id, - backend: playlist.backend, - kind: MediaKind.playlist, - title: playlist.title, - thumbPath: playlist.thumbPath, - serverId: playlist.serverId ?? client.serverId, - serverName: playlist.serverName, - ); - - final result = await showListDownloadOptionsAndQueue( - context, - rootMetadata: playlistMetadata, - targetType: ContentTypes.playlist, - items: items, - client: client, - downloadProvider: downloadProvider, - ); - if (result == null || !context.mounted) return; - - showSuccessSnackBar(context, result.toSnackBarMessage()); - } on CellularDownloadBlockedException { - if (context.mounted) { - showErrorSnackBar(context, t.settings.cellularDownloadBlocked); - } - } catch (e) { - appLogger.e('Failed to queue playlist download', error: e); - if (context.mounted) { - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } - } + ), + rootMetadata: collection, + targetType: ContentTypes.collection, + ); } /// Handle download action @@ -2039,265 +1958,3 @@ class MediaContextMenuState extends State { return widget.child; } } - -typedef _PickerPageLoader = Future> Function(int start, int size, AbortController abort); - -typedef _PickerItemBuilder = Widget Function(BuildContext context, T item); - -/// Shared loading, filtering, and TV focus shell for collection-style pickers. -class _PickerDialogScaffold extends StatefulWidget { - final String title; - final String searchHint; - final String emptyMessage; - final _PickerPageLoader loadPage; - final String Function(T item) itemTitle; - final _PickerItemBuilder itemBuilder; - - const _PickerDialogScaffold({ - required this.title, - required this.searchHint, - required this.emptyMessage, - required this.loadPage, - required this.itemTitle, - required this.itemBuilder, - }); - - @override - State<_PickerDialogScaffold> createState() => _PickerDialogScaffoldState(); -} - -class _PickerDialogScaffoldState extends State<_PickerDialogScaffold> { - static const int _pageSize = 100; - static const int _filterThreshold = 10; - - final _filterController = TextEditingController(); - final _filterFocusNode = FocusNode(debugLabel: 'PickerFilter'); - final _firstItemFocusNode = FocusNode(debugLabel: 'PickerFirstItem'); - final _abortController = AbortController(); - final _scrollController = ScrollController(); - final List _items = []; - List _filteredItems = []; - bool _isLoading = false; - bool _initialFocusRequested = false; - String? _errorMessage; - int? _totalCount; - - bool get _hasMore => _totalCount == null || _items.length < _totalCount!; - bool get _showFilter => _items.length >= _filterThreshold; - - @override - void initState() { - super.initState(); - _scrollController.addListener(_onScroll); - unawaited(_loadNextPage()); - } - - @override - void dispose() { - _abortController.abort(); - _scrollController.dispose(); - _filterController.dispose(); - _filterFocusNode.dispose(); - _firstItemFocusNode.dispose(); - super.dispose(); - } - - void _onScroll() { - if (!_scrollController.hasClients || !_hasMore || _isLoading) return; - final position = _scrollController.position; - if (position.pixels >= position.maxScrollExtent - 240) { - unawaited(_loadNextPage()); - } - } - - Future _loadNextPage() async { - if (_isLoading || !_hasMore) return; - setState(() { - _isLoading = true; - _errorMessage = null; - }); - try { - while (mounted && _hasMore) { - final page = await widget.loadPage(_items.length, _pageSize, _abortController); - if (!mounted) return; - setState(() { - _items.addAll(page.items); - _totalCount = page.totalCount; - _applyFilter(_filterController.text); - }); - if (_filterController.text.isEmpty || page.items.isEmpty) break; - } - if (!mounted) return; - setState(() => _isLoading = false); - } catch (e) { - if (!mounted) return; - setState(() { - _errorMessage = e.toString(); - _isLoading = false; - }); - } - _requestInitialFocus(); - } - - void _requestInitialFocus() { - if (_initialFocusRequested) return; - _initialFocusRequested = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - (_showFilter ? _filterFocusNode : _firstItemFocusNode).requestFocus(); - }); - } - - void _onFilterChanged(String query) { - setState(() => _applyFilter(query)); - if (query.isNotEmpty && _hasMore) { - unawaited(_loadNextPage()); - } - } - - void _applyFilter(String query) { - final lower = query.toLowerCase(); - _filteredItems = lower.isEmpty - ? List.of(_items) - : _items.where((item) => widget.itemTitle(item).toLowerCase().contains(lower)).toList(); - } - - @override - Widget build(BuildContext context) { - final showStatus = _hasMore || _isLoading || _errorMessage != null || _filteredItems.isEmpty; - return Focus( - onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), - child: AlertDialog( - title: Text(widget.title), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: .min, - children: [ - if (_showFilter) ...[ - FocusableTextField( - controller: _filterController, - focusNode: _filterFocusNode, - tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, - tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, - onNavigateDown: _firstItemFocusNode.requestFocus, - decoration: pillInputDecoration( - context, - hintText: widget.searchHint, - prefixIcon: const AppIcon(Symbols.search_rounded, size: 20), - ), - onChanged: _onFilterChanged, - ), - const SizedBox(height: 8), - ], - Flexible( - child: ListView.builder( - controller: _scrollController, - shrinkWrap: true, - itemCount: _filteredItems.length + 1 + (showStatus ? 1 : 0), - itemBuilder: (context, index) { - if (index == 0) { - return FocusableListTile( - focusNode: _firstItemFocusNode, - leading: const AppIcon(Symbols.add_rounded, fill: 1), - title: Text(t.common.createNew), - onTap: () => Navigator.pop(context, '_create_new'), - ); - } - - if (index <= _filteredItems.length) { - return widget.itemBuilder(context, _filteredItems[index - 1]); - } - - if (_errorMessage != null) { - return FocusableListTile( - leading: const AppIcon(Symbols.error_rounded, fill: 1), - title: Text(t.messages.errorLoading(error: _errorMessage!)), - onTap: _loadNextPage, - ); - } - if (_hasMore || _isLoading) { - if (_hasMore && !_isLoading) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_loadNextPage()); - }); - } - return const Padding( - padding: .all(16), - child: Center(child: CircularProgressIndicator()), - ); - } - return Padding( - padding: const .all(16), - child: Text(widget.emptyMessage, textAlign: TextAlign.center), - ); - }, - ), - ), - ], - ), - ), - actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)], - ), - ); - } -} - -/// Dialog to select a playlist or create a new one. -class _PlaylistSelectionDialog extends StatelessWidget { - final MediaServerClient client; - - const _PlaylistSelectionDialog({required this.client}); - - @override - Widget build(BuildContext context) { - return _PickerDialogScaffold( - title: t.playlists.selectPlaylist, - searchHint: t.playlists.searchPlaylists, - emptyMessage: t.playlists.noPlaylists, - loadPage: (start, size, abort) => - client.fetchPlaylistsPage(playlistType: 'video', smart: false, start: start, size: size, abort: abort), - itemTitle: (playlist) => playlist.title, - itemBuilder: (context, playlist) { - final leafCount = playlist.leafCount; - final subtitleText = leafCount == 1 ? t.playlists.oneItem : t.playlists.itemCount(count: leafCount ?? 0); - return FocusableListTile( - leading: playlist.smart - ? const AppIcon(Symbols.auto_awesome_rounded, fill: 1) - : const AppIcon(Symbols.playlist_play_rounded, fill: 1), - title: Text(playlist.title), - subtitle: playlist.leafCount != null ? Text(subtitleText) : null, - onTap: playlist.smart - ? null // Disable smart playlists - : () => Navigator.pop(context, playlist.id), - enabled: !playlist.smart, - ); - }, - ); - } -} - -/// Dialog to select a collection or create a new one -class _CollectionSelectionDialog extends StatelessWidget { - final MediaServerClient client; - final String libraryId; - - const _CollectionSelectionDialog({required this.client, required this.libraryId}); - - @override - Widget build(BuildContext context) { - return _PickerDialogScaffold( - title: t.collections.selectCollection, - searchHint: t.collections.searchCollections, - emptyMessage: t.libraries.noCollections, - loadPage: (start, size, abort) => client.fetchCollectionsPage(libraryId, start: start, size: size, abort: abort), - itemTitle: (collection) => collection.title ?? '', - itemBuilder: (context, collection) => FocusableListTile( - leading: const AppIcon(Symbols.collections_rounded, fill: 1), - title: Text(collection.title ?? ''), - subtitle: collection.childCount != null ? Text(t.playlists.itemCount(count: collection.childCount!)) : null, - onTap: () => Navigator.pop(context, collection.id), - ), - ); - } -}