refactor(music): share the playlist play/download/delete flows between the context menu and the detail screens

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).
This commit is contained in:
edde746
2026-08-17 19:02:21 +02:00
parent fb048097ab
commit 4c2f533eef
7 changed files with 549 additions and 537 deletions
+1 -8
View File
@@ -112,14 +112,7 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
Future<void> _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) {
+3 -11
View File
@@ -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<NowPlayingScreen>
_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<void> _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<void> _showSleepTimerSheet() async {
@@ -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<PlaylistDetai
/// now-playing) instead of the video play-queue launcher.
bool get _isAudioPlaylist => widget.playlist.playlistType == 'audio';
MusicPlayContext get _musicPlayContext =>
MusicPlayContext(id: widget.playlist.id, title: widget.playlist.title, kind: MusicPlayContextKind.playlist);
@override
Future<void> playItems() => _isAudioPlaylist ? _playAudioPlaylist(shuffle: false) : super.playItems();
@@ -93,14 +86,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
showAppSnackBar(context, emptyMessage);
return;
}
await playFetchedTracks(
await playAudioPlaylist(
context,
fetch: () async => _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<PlaylistDetai
];
}
/// Synthesise a [MediaItem] view of the current playlist for the
/// download_utils helpers.
MediaItem _playlistAsMetadata() => 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<PlaylistDetai
}
}
Future<void> _downloadPlaylist() async {
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
Future<void> _downloadPlaylist() => downloadPlaylist(
context,
client: mediaClient,
downloadProvider: Provider.of<DownloadProvider>(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<void> _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<void> _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
+103
View File
@@ -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<DownloadResult?> 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<void> fetchAndQueueListDownload(
BuildContext context, {
required MediaServerClient client,
required DownloadProvider downloadProvider,
required Future<List<MediaItem>> 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<void> 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>(T all, T unwatched) => [
@@ -565,3 +633,38 @@ List<FocusableAction> 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<void> 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);
}
}
+43
View File
@@ -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<void> 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<void> 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<void> navigateToAlbum(BuildContext context, MediaItem album) async {
await _contentNavigatorOf(context).push(MaterialPageRoute(builder: (context) => AlbumDetailScreen(album: album)));
@@ -145,6 +162,32 @@ Future<void> 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<void> playAudioPlaylist(
BuildContext context, {
required MediaServerClient client,
required MediaPlaylist playlist,
bool shuffle = false,
MediaItem? startTrack,
List<MediaItem>? 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.
+279
View File
@@ -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<T> = Future<LibraryPage<T>> Function(int start, int size, AbortController abort);
typedef _PickerItemBuilder<T> = Widget Function(BuildContext context, T item);
/// Shared loading, filtering, and TV focus shell for collection-style pickers.
class _PickerDialogScaffold<T> extends StatefulWidget {
final String title;
final String searchHint;
final String emptyMessage;
final _PickerPageLoader<T> loadPage;
final String Function(T item) itemTitle;
final _PickerItemBuilder<T> itemBuilder;
const _PickerDialogScaffold({
required this.title,
required this.searchHint,
required this.emptyMessage,
required this.loadPage,
required this.itemTitle,
required this.itemBuilder,
});
@override
State<_PickerDialogScaffold<T>> createState() => _PickerDialogScaffoldState<T>();
}
class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
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<T> _items = [];
List<T> _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<void> _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<MediaPlaylist>(
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<MediaItem>(
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),
),
);
}
}
+100 -443
View File
@@ -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<MediaContextMenu> {
final isDownloadablePlaylist =
isPlaylist && (playlist.playlistType == 'video' || playlist.playlistType == 'audio');
if ((isDownloadablePlaylist || isCollection) && !PlatformDetector.isAppleTV()) {
final hasRule = Provider.of<DownloadProvider>(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<DownloadProvider>(context, listen: false).hasSyncRule(_itemSyncRuleKey(context)),
hasAnyDownload: false,
downloadValue: isPlaylist ? 'download_playlist' : 'download_collection',
),
);
}
menuActions.add(
@@ -619,41 +633,13 @@ class MediaContextMenuState extends State<MediaContextMenu> {
mediaKind == MediaKind.album ||
mediaKind == MediaKind.track)) {
final downloadProvider = Provider.of<DownloadProvider>(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<MediaContextMenu> {
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<MediaContextMenu> {
final result = await showScopedDialog<String>(
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<MediaContextMenu> {
final result = await showScopedDialog<String>(
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<MediaContextMenu> {
/// in-memory queue locally.
Future<void> _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<MediaContextMenu> {
await launcher.launchFromCollectionOrPlaylist(item: widget.item, shuffle: shuffle);
}
Future<void> _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<void> _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async {
Future<void> _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<MediaContextMenu> {
);
}
/// Handle download collection action — opens the same sync/one-time dialog
/// as playlists, wired to [showListDownloadOptionsAndQueue].
Future<void> _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<void> _handleDownloadList(BuildContext context, {required bool isPlaylist}) async {
final downloadProvider = Provider.of<DownloadProvider>(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<void> _handleDownloadPlaylist(BuildContext context) async {
final playlist = _playlist!;
final downloadProvider = Provider.of<DownloadProvider>(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<MediaContextMenu> {
return widget.child;
}
}
typedef _PickerPageLoader<T> = Future<LibraryPage<T>> Function(int start, int size, AbortController abort);
typedef _PickerItemBuilder<T> = Widget Function(BuildContext context, T item);
/// Shared loading, filtering, and TV focus shell for collection-style pickers.
class _PickerDialogScaffold<T> extends StatefulWidget {
final String title;
final String searchHint;
final String emptyMessage;
final _PickerPageLoader<T> loadPage;
final String Function(T item) itemTitle;
final _PickerItemBuilder<T> itemBuilder;
const _PickerDialogScaffold({
required this.title,
required this.searchHint,
required this.emptyMessage,
required this.loadPage,
required this.itemTitle,
required this.itemBuilder,
});
@override
State<_PickerDialogScaffold<T>> createState() => _PickerDialogScaffoldState<T>();
}
class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
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<T> _items = [];
List<T> _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<void> _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<MediaPlaylist>(
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<MediaItem>(
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),
),
);
}
}