Files
plezy/lib/screens/playlist/playlist_detail_screen.dart
T
edde746 a9f0532f5f fix(ui): keep pushed screens clear of the Android navigation bar
Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is
36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16
disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only
SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on
API 35+ the window is edge-to-edge from the first frame and
MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation.

MainScreen's phone layout hides that. It supplies a bottomNavigationBar and
never sets extendBody, so Flutter's Scaffold strips padding.bottom from the
body MediaQuery and every tab is already safe. Routes pushed on the profile
navigator are full-screen siblings of MainScreen with no bottom bar, so they
receive the untouched inset and nothing consumes it - the last settings card
and the final log lines render under the back, home, and recents buttons.

Three shared hosts own most of those routes, so the inset is consumed there:
FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and
FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing
SliverSystemBottomInset, and the four screens that build their own Scaffold
around a CustomScrollView append it directly.

The new widget codifies the convention this repository had already written
down but open-coded - insets baked into the scroll content rather than a
SafeArea around the scroll view - so content still paints under the bar while
the scroll extent grows enough to bring the last row above it. It reads
padding from its own context and collapses to zero height wherever the inset
is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside
MainScreen's tab bodies. No platform branching, and it stacks additively with
the music detail screens' existing mini-player spacers, which is correct
because the mini-player itself floats above the navigation bar on a pushed
route.

Scroll views that are not sliver lists take the inset in their own padding:
the companion remote's ListView, the auth screen's scroll container, and the
two SliverFillRemaining sign-in forms, whose children size themselves from
the extent remaining before them and so cannot be helped by a trailing
sliver. The logs empty state is left alone for the same reason inverted - it
already fills the viewport, and a trailing inset would only add scroll slack.

Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation:
Settings, Logs, and Video Playback all end clear of the bar.

close #1766
2026-08-03 00:05:21 +02:00

841 lines
28 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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';
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 '../libraries/content_state_builder.dart';
import '../../mixins/grid_focus_node_mixin.dart';
import '../../widgets/overlay_sheet.dart';
import '../../widgets/system_bottom_inset.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;
}
await playFetchedTracks(
context,
fetch: () async => _isPlaylistFullyLoaded ? items : await fetchAllPlaylistItems(mediaClient, widget.playlist.id),
playContext: _musicPlayContext,
onError: (e, stackTrace) =>
showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title)),
startTrack: startTrack,
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 = syncRuleKey;
// 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),
],
...buildSyncRuleActions(
context,
ruleKey: ruleKey,
displayTitle: widget.playlist.title,
hasRule: hasRule,
showDownload: isDownloadablePlaylist && (items.isNotEmpty || hasRule),
onDownload: _downloadPlaylist,
),
// 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,
);
// 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 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);
}
}
/// 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)
ContinuationStatusSliver(
error: _continuation.error,
onRetry: _retryPlaylistContinuation,
retryFocusNode: _continuationRetryFocusNode,
onNavigateUp: _isReadOnly ? navigateToGrid : _listFocusNode.requestFocus,
onBack: handleBackFromContent,
),
],
const SliverSystemBottomInset(),
],
);
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,
),
);
},
);
},
);
}
}