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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user