Files
plezy/lib/screens/playlist/playlist_detail_screen.dart
T

898 lines
30 KiB
Dart

import 'dart:async';
import '../../media/ids.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.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/error_message_utils.dart';
import '../../utils/continuation_pagination_coordinator.dart';
import '../../utils/music_navigation.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import 'package:provider/provider.dart';
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';
import '../../widgets/listenable_selector.dart';
import '../base_media_list_detail_screen.dart';
import '../focusable_detail_screen_mixin.dart';
import '../../mixins/grid_focus_node_mixin.dart';
import '../../widgets/overlay_sheet.dart';
/// Screen to display the contents of a playlist
class PlaylistDetailScreen extends StatefulWidget {
final MediaPlaylist playlist;
const PlaylistDetailScreen({super.key, required this.playlist});
@override
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
}
class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetailScreen>
with
StandardItemLoader<PlaylistDetailScreen>,
GridFocusNodeMixin<PlaylistDetailScreen>,
FocusableDetailScreenMixin<PlaylistDetailScreen> {
static const int _pageSize = playlistItemsPageSize;
@override
Object get mediaItem => widget.playlist;
@override
String get title => widget.playlist.title;
@override
String get emptyMessage => t.playlists.emptyPlaylist;
@override
IconData get emptyIcon => Symbols.playlist_play_rounded;
@override
bool get hasItems => items.isNotEmpty;
/// True when the playlist can't be reordered or have items removed.
/// Currently only Plex smart playlists (server-side rule-based; managed via
/// filter rules, not direct edits). Jellyfin has no equivalent concept.
bool get _isReadOnly => widget.playlist.smart;
/// Audio playlists play through the music session (mini-player /
/// 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();
@override
Future<void> shufflePlayItems() => _isAudioPlaylist ? _playAudioPlaylist(shuffle: true) : super.shufflePlayItems();
/// Uses the already-loaded items when the playlist is fully paged in;
/// otherwise fetches the full item list (one loader round-trip) so the
/// queue isn't truncated to the first page.
Future<void> _playAudioPlaylist({required bool shuffle, MediaItem? startTrack}) async {
if (items.isEmpty) {
showAppSnackBar(context, emptyMessage);
return;
}
if (!ensureMusicPlaybackAvailable(context)) return;
final service = context.read<MusicPlaybackService>();
final intent = service.beginPlayIntent();
List<MediaItem> tracks;
if (_isPlaylistFullyLoaded) {
tracks = items;
} else {
try {
tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
} catch (e, stackTrace) {
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title);
showErrorSnackBar(context, message);
return;
}
}
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle);
}
@override
List<FocusableAction> getAppBarActions() {
// Video AND audio playlists download (tracks queue through the same list
// pipeline); photo/mixed playlists keep the affordance hidden.
final isDownloadablePlaylist = widget.playlist.playlistType == 'video' || _isAudioPlaylist;
final ruleKey = _playlistSyncRuleKey();
// Select the specific bool we care about so unrelated DownloadProvider
// ticks (e.g. active download progress) don't rebuild the app bar.
final hasRule = isDownloadablePlaylist && context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
return [
if (items.isNotEmpty) ...[
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
],
if (!PlatformDetector.isAppleTV() && isDownloadablePlaylist && (items.isNotEmpty || hasRule))
FocusableAction(
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
onPressed: hasRule ? _managePlaylistSyncRule : _downloadPlaylist,
iconColor: hasRule ? Colors.teal : null,
),
if (!PlatformDetector.isAppleTV() && hasRule)
FocusableAction(
icon: Symbols.sync_disabled_rounded,
tooltip: t.downloads.removeSyncRule,
onPressed: _removePlaylistSyncRule,
),
// Delete works on both backends now (Jellyfin uses /Items/{id} DELETE,
// wrapped in the neutral [MediaServerClient.deletePlaylist]). Smart
// playlists are still skipped — they're a Plex concept and are
// managed server-side via filter rules, not via DELETE.
if (!widget.playlist.smart)
FocusableAction(
icon: Symbols.delete_rounded,
tooltip: t.playlists.delete,
onPressed: _deletePlaylist,
iconColor: Colors.red,
),
];
}
/// 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,
);
String _playlistSyncRuleKey() {
final serverId = widget.playlist.serverId ?? mediaClient.serverId;
return context.read<DownloadProvider>().syncRuleKeyForClient(
mediaClient,
widget.playlist.id,
serverId: ServerId(serverId),
);
}
Future<void> _managePlaylistSyncRule() =>
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _playlistSyncRuleKey());
Future<void> _removePlaylistSyncRule() => removeSyncRuleAndSnack(
context,
downloadProvider: context.read<DownloadProvider>(),
globalKey: _playlistSyncRuleKey(),
displayTitle: widget.playlist.title,
);
// Focus management for regular (non-smart) reorderable lists
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry');
// Navigation state for regular (non-smart) playlists
int _focusedIndex = 0;
int _focusedColumn = 0; // 0=content, 1=drag handle, 2=remove button
final ValueNotifier<int> _focusRevision = ValueNotifier<int>(0);
void _notifyFocusChanged() => _focusRevision.value++;
// Move mode state
int? _movingIndex;
int? _originalIndex;
List<MediaItem>? _originalOrder;
late final ContinuationPaginationCoordinator<MediaItem> _continuation = ContinuationPaginationCoordinator<MediaItem>(
loadPage: _fetchPlaylistContinuationPage,
onPage: _applyPlaylistContinuationPage,
onStateChanged: _handleContinuationStateChanged,
onError: (error, stackTrace) =>
appLogger.w('Failed to finish loading playlist items', error: error, stackTrace: stackTrace),
);
bool get _isPlaylistFullyLoaded => _continuation.totalCount != null && items.length >= _continuation.totalCount!;
bool get _canEditPlaylist => !_isReadOnly && _isPlaylistFullyLoaded;
// Estimated item height for scroll-into-view (card + vertical margins)
static const double _estimatedItemHeight = 114.0;
@override
void dispose() {
_continuation.dispose();
_listFocusNode.dispose();
_continuationRetryFocusNode.dispose();
_focusRevision.dispose();
disposeFocusResources();
super.dispose();
}
@override
Future<List<MediaItem>> fetchItems() async {
return fetchAllPlaylistItems(mediaClient, widget.playlist.id);
}
@override
Future<void> loadItems() async {
if (mounted) {
setState(() {
isLoading = true;
errorMessage = null;
items = [];
_focusedIndex = 0;
_focusedColumn = 0;
_movingIndex = null;
_originalIndex = null;
_originalOrder = null;
});
}
try {
LibraryPage<MediaItem>? firstPage;
final applied = await _continuation.runNewGeneration(() async {
firstPage = await mediaClient.fetchPlaylistPage(widget.playlist.id, start: 0, size: _pageSize);
});
if (!mounted || !applied) return;
final page = firstPage!;
_continuation.setContinuation(startIndex: page.items.length, totalCount: page.totalCount);
setState(() {
items = List.of(page.items);
isLoading = false;
});
appLogger.d('Loaded ${page.items.length} of ${page.totalCount} items for playlist: ${widget.playlist.title}');
_autoFocusAfterLoad();
if (_continuation.hasMore) unawaited(_continuation.loadRemaining());
} catch (e, stackTrace) {
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title);
if (!mounted) return;
setState(() {
errorMessage = message;
isLoading = false;
});
}
}
Future<ContinuationPage<MediaItem>> _fetchPlaylistContinuationPage(int startIndex) async {
final page = await mediaClient.fetchPlaylistPage(widget.playlist.id, start: startIndex, size: _pageSize);
return ContinuationPage(items: page.items, totalCount: page.totalCount, consumedCount: page.items.length);
}
void _applyPlaylistContinuationPage(ContinuationPage<MediaItem> page) {
if (!mounted) return;
setState(() {
items = List.of(items)..addAll(page.items);
});
}
void _handleContinuationStateChanged() {
if (!mounted) return;
setState(() {
if (!_continuation.isLoading && _focusedColumn != 0 && !_canEditPlaylist) {
_focusedColumn = 0;
}
});
}
void _retryPlaylistContinuation() => unawaited(_continuation.retry());
void _autoFocusAfterLoad() {
if (mounted && items.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (InputModeTracker.isKeyboardMode(context)) {
setState(() {
isAppBarFocused = false;
_focusedIndex = 0;
_focusedColumn = 0;
});
if (_isReadOnly) {
firstItemFocusNode.requestFocus();
} else {
_listFocusNode.requestFocus();
}
}
});
}
}
@override
String getLoadSuccessMessage(int itemCount) {
return 'Loaded $itemCount items for playlist: ${widget.playlist.title}';
}
/// Navigate from app bar down to content - overridden to handle both grid and list
@override
void navigateToGrid() {
if (!hasItems) return;
if (_isReadOnly) {
super.navigateToGrid();
} else {
setState(() {
isAppBarFocused = false;
});
_listFocusNode.requestFocus();
}
}
Future<void> _downloadPlaylist() async {
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
try {
final allItems = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
if (!mounted) return;
final result = await showPlaylistDownloadOptionsAndQueue(
context,
playlistMetadata: _playlistAsMetadata(),
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);
}
}
/// 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
/// into the client implementations.
MediaItem? _afterItemForIndex(int newIndex) {
if (newIndex == 0) return null;
return items[newIndex - 1];
}
Future<void> _onReorder(int oldIndex, int newIndex) async {
if (!_canEditPlaylist) return;
// Can't reorder if indices are the same
if (oldIndex == newIndex) return;
final movedItem = items[oldIndex];
appLogger.d('Reordering item from $oldIndex to $newIndex');
// Optimistically update UI
setState(() {
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
bool success = false;
try {
success = await mediaClient.movePlaylistItem(
playlistId: widget.playlist.id,
item: movedItem,
newIndex: newIndex,
afterItem: _afterItemForIndex(newIndex),
);
} catch (e) {
appLogger.e('Failed to reorder playlist item', error: e);
}
if (!success) {
// Revert on failure
appLogger.e('Failed to reorder playlist item, reverting UI');
if (mounted) {
setState(() {
final item = items.removeAt(newIndex);
items.insert(oldIndex, item);
});
showErrorSnackBar(context, t.playlists.errorReordering);
}
}
}
/// Persist a move that was already done in the UI (during move mode).
/// The item is already at newIndex in the items list.
Future<void> _persistMoveToServer(int originalIndex, int newIndex) async {
final movedItem = items[newIndex];
appLogger.d('Persisting move from $originalIndex to $newIndex');
bool success = false;
try {
success = await mediaClient.movePlaylistItem(
playlistId: widget.playlist.id,
item: movedItem,
newIndex: newIndex,
afterItem: _afterItemForIndex(newIndex),
);
} catch (e) {
appLogger.e('Failed to persist move', error: e);
}
if (!success) {
// Revert on failure
appLogger.e('Failed to persist move, reverting UI');
if (mounted) {
_revertMove(newIndex, originalIndex);
showErrorSnackBar(context, t.playlists.errorReordering);
}
}
}
/// Revert a move in the UI by moving item from [fromIndex] back to [toIndex].
void _revertMove(int fromIndex, int toIndex) {
setState(() {
final item = items.removeAt(fromIndex);
items.insert(toIndex, item);
_focusedIndex = toIndex;
});
}
Future<void> _removeItem(int index) async {
if (!_canEditPlaylist) return;
if (items.isEmpty || index < 0 || index >= items.length) return;
final item = items[index];
final previousItem = index > 0 ? items[index - 1] : null;
final nextItem = index + 1 < items.length ? items[index + 1] : null;
appLogger.d('Removing item ${item.title} from playlist');
// Optimistically update UI
setState(() {
items.removeAt(index);
if (_focusedIndex >= items.length) {
_focusedIndex = (items.length - 1).clamp(0, items.length);
}
if (items.isEmpty) {
_focusedColumn = 0;
}
});
bool success = false;
try {
success = await mediaClient.removeFromPlaylist(playlistId: widget.playlist.id, item: item);
} catch (e) {
appLogger.e('Failed to remove playlist item', error: e);
}
if (mounted) {
if (success) {
showSuccessSnackBar(context, t.playlists.itemRemoved);
} else {
// Restore relative to surviving neighbors; concurrent mutations can
// make the original numeric index stale.
appLogger.e('Failed to remove playlist item, reverting UI');
setState(() {
final nextIndex = nextItem == null ? -1 : items.indexOf(nextItem);
final previousIndex = previousItem == null ? -1 : items.indexOf(previousItem);
final restoreIndex = nextIndex >= 0
? nextIndex
: previousIndex >= 0
? previousIndex + 1
: index.clamp(0, items.length);
items.insert(restoreIndex, item);
_focusedIndex = restoreIndex;
});
showErrorSnackBar(context, t.playlists.errorRemoving);
}
}
}
Future<void> _playFromItem(int index) async {
if (items.isEmpty || index < 0 || index >= items.length) return;
final selectedItem = items[index];
if (_isAudioPlaylist) {
await _playAudioPlaylist(shuffle: false, startTrack: selectedItem);
return;
}
final launcher = MediaListPlaybackLauncher.forItem(context, widget.playlist);
await launcher.launchFromCollectionOrPlaylist(
item: widget.playlist,
shuffle: false,
startItem: selectedItem,
showLoadingIndicator: true,
);
}
/// Ensure the focused item is visible in the list using scroll arithmetic.
/// Uses estimated item height instead of per-item GlobalKeys.
void _ensureFocusedVisible() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !scrollController.hasClients) return;
final targetOffset = _focusedIndex * _estimatedItemHeight;
final viewportHeight = scrollController.position.viewportDimension;
final currentOffset = scrollController.offset;
// Check if the item is outside the visible area (with some padding)
if (targetOffset < currentOffset || targetOffset > currentOffset + viewportHeight - _estimatedItemHeight) {
// Scroll so the item sits ~25% from the top of the viewport
final scrollTo = (targetOffset - viewportHeight * 0.25).clamp(
scrollController.position.minScrollExtent,
scrollController.position.maxScrollExtent,
);
scrollController.animateTo(scrollTo, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
});
}
/// Handle key events for list navigation
KeyEventResult _handleListKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final backResult = handleBackKeyAction(event, () {
if (_movingIndex != null) {
// Cancel move mode and suppress route-level back handling.
backHandledByKeyEvent = true;
_cancelMoveMode();
} else {
// Navigate to the app bar and suppress route-level back handling.
handleBackFromContent();
}
});
if (backResult != KeyEventResult.ignored) {
return backResult;
}
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (_movingIndex != null) {
// Move mode - arrows reorder the item
if (key.isUpKey && _movingIndex! > 0) {
setState(() {
final item = items.removeAt(_movingIndex!);
items.insert(_movingIndex! - 1, item);
_movingIndex = _movingIndex! - 1;
_focusedIndex = _movingIndex!;
});
_ensureFocusedVisible();
return KeyEventResult.handled;
}
if (key.isDownKey && _movingIndex! < items.length - 1) {
setState(() {
final item = items.removeAt(_movingIndex!);
items.insert(_movingIndex! + 1, item);
_movingIndex = _movingIndex! + 1;
_focusedIndex = _movingIndex!;
});
_ensureFocusedVisible();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
// Confirm move - persist to server (UI is already updated during move)
final oldIndex = _originalIndex!;
final newIndex = _movingIndex!;
setState(() {
_movingIndex = null;
_originalIndex = null;
_originalOrder = null;
// Keep focus on the moved item at its new position
_focusedIndex = newIndex;
_focusedColumn = 0;
});
// Persist the change via API (list is already in correct order)
_persistMoveToServer(oldIndex, newIndex);
return KeyEventResult.handled;
}
} else {
// Navigation mode
if (key.isUpKey) {
if (_focusedIndex > 0) {
_focusedIndex--;
_focusedColumn = 0;
_notifyFocusChanged();
_ensureFocusedVisible();
} else {
// First item - navigate to app bar
navigateToAppBar();
}
return KeyEventResult.handled;
}
if (key.isDownKey) {
if (_focusedIndex < items.length - 1) {
_focusedIndex++;
_focusedColumn = 0;
_notifyFocusChanged();
_ensureFocusedVisible();
return KeyEventResult.handled;
}
if (_continuation.error != null) {
_continuationRetryFocusNode.requestFocus();
return KeyEventResult.handled;
}
}
if (key.isLeftKey) {
// Navigate left within columns
if (_focusedColumn == 0 && _canEditPlaylist) {
// Go to drag handle (column 1)
_focusedColumn = 1;
_notifyFocusChanged();
return KeyEventResult.handled;
} else if (_focusedColumn == 2) {
// Go back to content
_focusedColumn = 0;
_notifyFocusChanged();
return KeyEventResult.handled;
}
}
if (key.isRightKey) {
// Navigate right within columns
if (_focusedColumn == 0 && _canEditPlaylist) {
// Go to remove button (column 2)
_focusedColumn = 2;
_notifyFocusChanged();
return KeyEventResult.handled;
} else if (_focusedColumn == 1) {
// Go to content from drag handle
_focusedColumn = 0;
_notifyFocusChanged();
return KeyEventResult.handled;
}
}
if (key.isSelectKey) {
if (_focusedColumn == 0) {
// Play from this item
_playFromItem(_focusedIndex);
} else if (_focusedColumn == 1 && _canEditPlaylist) {
// Enter move mode
setState(() {
_movingIndex = _focusedIndex;
_originalIndex = _focusedIndex;
_originalOrder = List.from(items);
});
} else if (_focusedColumn == 2 && _canEditPlaylist) {
// Remove item
_removeItem(_focusedIndex);
}
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
/// Cancel move mode if active, returns true if cancelled
bool _cancelMoveMode() {
if (_movingIndex != null) {
setState(() {
if (_originalOrder != null) {
items = List.from(_originalOrder!);
}
_focusedIndex = _originalIndex ?? 0;
_movingIndex = null;
_originalIndex = null;
_originalOrder = null;
});
return true;
}
return false;
}
/// Handle route back from [OverlaySheetHost], extending the mixin with move mode support.
bool _handleBackNavigation() {
// If BACK was already handled by a key event, don't pop
if (backHandledByKeyEvent) {
backHandledByKeyEvent = false;
return false;
}
// If in move mode, cancel move instead of navigating
if (_movingIndex != null) {
_cancelMoveMode();
return false;
}
return handleBackNavigation();
}
@override
Widget build(BuildContext context) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final allowsNativeBackGesture = PlatformDetector.isHandheldIOS(context);
// For regular playlists, wrap the scroll view with the Focus widget
// (Focus is a RenderObject widget and cannot directly wrap a sliver)
final needsListFocus = !_isReadOnly && items.isNotEmpty;
Widget scrollView = CustomScrollView(
primary: true,
slivers: [
CustomAppBar(
title: Column(
crossAxisAlignment: .start,
children: [
Text(widget.playlist.title, style: const TextStyle(fontSize: 16)),
if (widget.playlist.smart)
Row(
mainAxisSize: .min,
children: [
AppIcon(
Symbols.auto_awesome_rounded,
fill: 1,
size: 12,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
Text(
t.playlists.smartPlaylist,
style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.primary, fontWeight: .normal),
),
],
),
],
),
actions: buildFocusableAppBarActions(),
),
...buildStateSlivers(),
if (items.isNotEmpty) ...[
if (_isReadOnly)
// Smart playlists / Jellyfin playlists: focusable grid view
// (read-only, no reordering or removal)
buildFocusableGrid(items: items, onRefresh: updateItem, shape: _isAudioPlaylist ? CardShape.square : null)
else
// Plex regular playlists: sliver reorderable list
_buildReorderableList(isKeyboardMode),
if (_continuation.isLoading || _continuation.error != null) _buildPlaylistContinuationStatusSliver(),
],
],
);
if (needsListFocus) {
scrollView = Focus(
autofocus: isKeyboardMode && !isAppBarFocused,
focusNode: _listFocusNode,
onKeyEvent: _handleListKeyEvent,
onFocusChange: (hasFocus) {
if (hasFocus && mounted) {
setState(() {
isAppBarFocused = false;
});
}
},
child: scrollView,
);
}
return OverlaySheetHost(
canPop: allowsNativeBackGesture && _movingIndex == null,
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
final shouldPop = _handleBackNavigation();
if (shouldPop && mounted) {
Navigator.pop(context);
}
},
child: PrimaryScrollController(
controller: scrollController,
child: IosStatusBarTapScrollToTop(
controller: scrollController,
child: Scaffold(body: scrollView),
),
),
);
}
/// Build a reorderable list for regular playlists with focus support
Widget _buildReorderableList(bool _) {
return SliverReorderableList(
onReorderItem: _onReorder,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final keyId = switch (item) {
PlexMediaItem(:final playlistItemId?) => 'p:$playlistItemId',
JellyfinMediaItem(:final playlistItemId?) => 'j:$playlistItemId',
_ => item.id,
};
return ListenableSelector<(bool, int?, bool)>(
key: ValueKey(keyId),
listenable: _focusRevision,
selector: () {
final focused = index == _focusedIndex && !isAppBarFocused;
return (focused, focused ? _focusedColumn : null, index == _movingIndex);
},
builder: (context, focusState, _) {
final inKeyboardMode = InputModeTracker.isKeyboardMode(context);
final isFocused = inKeyboardMode && focusState.$1;
return RepaintBoundary(
child: PlaylistItemCard(
item: item,
index: index,
onRemove: () => _removeItem(index),
onTap: () => _playFromItem(index),
onRefresh: updateItem,
canReorder: _canEditPlaylist,
isFocused: isFocused,
focusedColumn: isFocused ? focusState.$2 : null,
isMoving: focusState.$3,
),
);
},
);
},
);
}
Widget _buildPlaylistContinuationStatusSliver() {
final exception = _continuation.error;
final error = exception == null ? null : t.messages.errorLoading(error: exception.toString());
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: error == null
? const CircularProgressIndicator()
: Column(
mainAxisSize: .min,
children: [
Text(error, textAlign: TextAlign.center),
const SizedBox(height: 8),
FocusableButton(
focusNode: _continuationRetryFocusNode,
onPressed: _retryPlaylistContinuation,
onNavigateUp: _isReadOnly ? navigateToGrid : _listFocusNode.requestFocus,
onBack: handleBackFromContent,
child: TextButton(onPressed: _retryPlaylistContinuation, child: Text(t.common.retry)),
),
],
),
),
),
);
}
}