Files
plezy/lib/screens/playlist/playlist_detail_screen.dart
T
edde746 4307c49cd2 refactor: remove unreachable code paths and unused members
Drops dead code across services, models, utils and widgets, including the
connection auth service, which had no implementer, and the Live TV DVR
provisioning models, which had no caller.

Tests that only covered deleted behaviour are removed or trimmed. No
behaviour change.
2026-07-26 06:09:47 +02:00

892 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 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;
bool _isPlaylistMutationPending = false;
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;
bool get _canMutatePlaylist => _canEditPlaylist && !_isPlaylistMutationPending;
// 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<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 && !_canMutatePlaylist) {
_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();
}
}
});
}
}
/// 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 (!_canMutatePlaylist) return;
if (oldIndex < 0 || oldIndex >= items.length || newIndex < 0 || newIndex >= items.length) return;
if (oldIndex == newIndex) return;
final originalOrder = List<MediaItem>.of(items);
final movedItem = items[oldIndex];
appLogger.d('Reordering item from $oldIndex to $newIndex');
setState(() {
_isPlaylistMutationPending = true;
_focusedColumn = 0;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
final afterItem = _afterItemForIndex(newIndex);
await _persistMoveToServer(
originalIndex: oldIndex,
newIndex: newIndex,
movedItem: movedItem,
afterItem: afterItem,
originalOrder: originalOrder,
);
}
/// 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({
required int originalIndex,
required int newIndex,
required MediaItem movedItem,
required MediaItem? afterItem,
required List<MediaItem> originalOrder,
}) async {
appLogger.d('Persisting move from $originalIndex to $newIndex');
Object? failure;
var success = false;
try {
try {
success = await mediaClient.movePlaylistItem(
playlistId: widget.playlist.id,
item: movedItem,
newIndex: newIndex,
afterItem: afterItem,
);
} catch (e, stackTrace) {
failure = e;
appLogger.e('Failed to persist move', error: e, stackTrace: stackTrace);
}
if (!mounted || success) return;
appLogger.e('Failed to persist move, recovering UI');
if (failure == null) {
_restorePlaylistOrder(originalOrder, focusedIndex: originalIndex);
} else {
await loadItems();
}
if (mounted) showErrorSnackBar(context, t.playlists.errorReordering);
} finally {
if (mounted) {
setState(() => _isPlaylistMutationPending = false);
}
}
}
Future<void> _removeItem(int index) async {
if (!_canMutatePlaylist) return;
if (items.isEmpty || index < 0 || index >= items.length) return;
final originalOrder = List<MediaItem>.of(items);
final item = items[index];
appLogger.d('Removing item ${item.title} from playlist');
setState(() {
_isPlaylistMutationPending = true;
_focusedColumn = 0;
items.removeAt(index);
if (_focusedIndex >= items.length) {
_focusedIndex = (items.length - 1).clamp(0, items.length);
}
});
Object? failure;
var success = false;
try {
try {
success = await mediaClient.removeFromPlaylist(playlistId: widget.playlist.id, item: item);
} catch (e, stackTrace) {
failure = e;
appLogger.e('Failed to remove playlist item', error: e, stackTrace: stackTrace);
}
if (!mounted) return;
if (success) {
showSuccessSnackBar(context, t.playlists.itemRemoved);
return;
}
appLogger.e('Failed to remove playlist item, recovering UI');
if (failure == null) {
_restorePlaylistOrder(originalOrder, focusedIndex: index);
} else {
await loadItems();
}
if (mounted) showErrorSnackBar(context, t.playlists.errorRemoving);
} finally {
if (mounted) {
setState(() => _isPlaylistMutationPending = false);
}
}
}
void _restorePlaylistOrder(List<MediaItem> order, {required int focusedIndex}) {
if (!mounted) return;
setState(() {
items = List<MediaItem>.of(order);
_focusedIndex = items.isEmpty ? 0 : focusedIndex.clamp(0, items.length - 1);
_focusedColumn = 0;
_movingIndex = null;
_originalIndex = null;
_originalOrder = null;
});
}
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!;
final originalOrder = List<MediaItem>.of(_originalOrder!);
final movedItem = items[newIndex];
final afterItem = _afterItemForIndex(newIndex);
setState(() {
_isPlaylistMutationPending = true;
_movingIndex = null;
_originalIndex = null;
_originalOrder = null;
// Keep focus on the moved item at its new position
_focusedIndex = newIndex;
_focusedColumn = 0;
});
unawaited(
_persistMoveToServer(
originalIndex: oldIndex,
newIndex: newIndex,
movedItem: movedItem,
afterItem: afterItem,
originalOrder: originalOrder,
),
);
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 && _canMutatePlaylist) {
// 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 && _canMutatePlaylist) {
// 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 && _canMutatePlaylist) {
// Enter move mode
setState(() {
_movingIndex = _focusedIndex;
_originalIndex = _focusedIndex;
_originalOrder = List.from(items);
});
} else if (_focusedColumn == 2 && _canMutatePlaylist) {
// 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: _canMutatePlaylist ? () => _removeItem(index) : null,
onTap: () => _playFromItem(index),
onRefresh: updateItem,
canReorder: _canMutatePlaylist,
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)),
),
],
),
),
),
);
}
}