fix: dpad support on collections & playlists screens
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../widgets/media_grid_sliver.dart';
|
||||
import '../widgets/focused_scroll_scaffold.dart';
|
||||
import '../widgets/focusable_media_card.dart';
|
||||
import '../widgets/media_grid_delegate.dart';
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
import 'focusable_detail_screen_mixin.dart';
|
||||
|
||||
/// Screen to display the contents of a collection
|
||||
class CollectionDetailScreen extends StatefulWidget {
|
||||
@@ -19,7 +25,7 @@ class CollectionDetailScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionDetailScreen>
|
||||
with StandardItemLoader<CollectionDetailScreen> {
|
||||
with StandardItemLoader<CollectionDetailScreen>, FocusableDetailScreenMixin<CollectionDetailScreen> {
|
||||
@override
|
||||
PlexMetadata get mediaItem => widget.collection;
|
||||
|
||||
@@ -29,11 +35,30 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
@override
|
||||
String get emptyMessage => t.collections.empty;
|
||||
|
||||
@override
|
||||
bool get hasItems => items.isNotEmpty;
|
||||
|
||||
@override
|
||||
int get appBarButtonCount => items.isNotEmpty ? 3 : 1; // play, shuffle, delete (or just delete if empty)
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusResources();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> fetchItems() async {
|
||||
return await client.getCollectionItems(widget.collection.ratingKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
contentVersion++;
|
||||
await super.loadItems();
|
||||
autoFocusFirstItemAfterLoad();
|
||||
}
|
||||
|
||||
@override
|
||||
String getLoadErrorMessage(Object error) {
|
||||
return t.collections.failedToLoadItems(error: error.toString());
|
||||
@@ -44,11 +69,28 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
return 'Loaded $itemCount items for collection: ${widget.collection.title}';
|
||||
}
|
||||
|
||||
Future<void> _deleteCollection() async {
|
||||
// Get library section ID from the collection or its items
|
||||
int? sectionId = widget.collection.librarySectionID;
|
||||
@override
|
||||
List<AppBarButtonConfig> getAppBarButtons() {
|
||||
final buttons = <AppBarButtonConfig>[];
|
||||
if (items.isNotEmpty) {
|
||||
buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.discover.play, onPressed: playItems));
|
||||
buttons.add(
|
||||
AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
||||
);
|
||||
}
|
||||
buttons.add(
|
||||
AppBarButtonConfig(
|
||||
icon: Symbols.delete_rounded,
|
||||
tooltip: t.common.delete,
|
||||
onPressed: _deleteCollection,
|
||||
color: Colors.red,
|
||||
),
|
||||
);
|
||||
return buttons;
|
||||
}
|
||||
|
||||
// If collection doesn't have it, try to get it from loaded items
|
||||
Future<void> _deleteCollection() async {
|
||||
int? sectionId = widget.collection.librarySectionID;
|
||||
if (sectionId == null && items.isNotEmpty) {
|
||||
sectionId = items.first.librarySectionID;
|
||||
}
|
||||
@@ -60,7 +102,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.collections.deleteCollection,
|
||||
@@ -75,13 +116,11 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.collections.deleted);
|
||||
Navigator.pop(context, true); // Return true to indicate refresh needed
|
||||
} else {
|
||||
showErrorSnackBar(context, t.collections.deleteFailed);
|
||||
}
|
||||
if (success) {
|
||||
showSuccessSnackBar(context, t.collections.deleted);
|
||||
Navigator.pop(context, true);
|
||||
} else {
|
||||
showErrorSnackBar(context, t.collections.deleteFailed);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete collection', error: e);
|
||||
@@ -91,22 +130,65 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
}
|
||||
}
|
||||
|
||||
int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final screenWidth = MediaQuery.of(context).size.width - 16;
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(widget.collection.title),
|
||||
actions: buildAppBarActions(onDelete: _deleteCollection),
|
||||
slivers: [
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
MediaGridSliver(
|
||||
items: items,
|
||||
onRefresh: updateItem,
|
||||
collectionId: widget.collection.ratingKey,
|
||||
onListRefresh: loadItems,
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) return;
|
||||
final shouldPop = handleBackNavigation();
|
||||
if (shouldPop && mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.collection.title), actions: buildFocusableAppBarActions()),
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty) _buildFocusableGrid(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFocusableGrid() {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
final columnCount = _getGridColumnCount(context, settingsProvider);
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final inFirstRow = isFirstRow(index, columnCount);
|
||||
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
focusNode: focusNode,
|
||||
onRefresh: updateItem,
|
||||
collectionId: widget.collection.ratingKey,
|
||||
onListRefresh: loadItems,
|
||||
onNavigateUp: inFirstRow ? navigateToAppBar : null,
|
||||
onBack: handleBackFromContent,
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Configuration for app bar buttons
|
||||
class AppBarButtonConfig {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
final Color? color;
|
||||
|
||||
const AppBarButtonConfig({required this.icon, required this.tooltip, required this.onPressed, this.color});
|
||||
}
|
||||
|
||||
/// Mixin that provides common focus navigation functionality for detail screens.
|
||||
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
|
||||
mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T> {
|
||||
// Scroll controller for scrolling to top when app bar is focused
|
||||
final ScrollController scrollController = ScrollController();
|
||||
|
||||
// App bar focus nodes
|
||||
final FocusNode playButtonFocusNode = FocusNode(debugLabel: 'detail_play');
|
||||
final FocusNode shuffleButtonFocusNode = FocusNode(debugLabel: 'detail_shuffle');
|
||||
final FocusNode deleteButtonFocusNode = FocusNode(debugLabel: 'detail_delete');
|
||||
|
||||
// Grid item focus
|
||||
final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item');
|
||||
final Map<int, FocusNode> gridItemFocusNodes = {};
|
||||
|
||||
// Focus restoration
|
||||
int? lastFocusedIndex;
|
||||
int contentVersion = 0;
|
||||
int lastFocusedContentVersion = 0;
|
||||
|
||||
// App bar focus state
|
||||
bool isAppBarFocused = false;
|
||||
int appBarFocusedButton = 0; // 0=play, 1=shuffle, 2=delete (or less if fewer buttons)
|
||||
|
||||
// Flag to prevent PopScope from exiting when BACK was handled by a key handler
|
||||
bool backHandledByKeyEvent = false;
|
||||
|
||||
/// Number of app bar buttons (override if different from 3)
|
||||
int get appBarButtonCount => 3;
|
||||
|
||||
/// Called when items are available and we want to check if focus should be set
|
||||
bool get hasItems;
|
||||
|
||||
/// Called to get the list of app bar button configurations
|
||||
List<AppBarButtonConfig> getAppBarButtons();
|
||||
|
||||
/// Dispose focus-related resources. Call this from your dispose() method.
|
||||
void disposeFocusResources() {
|
||||
scrollController.dispose();
|
||||
playButtonFocusNode.dispose();
|
||||
shuffleButtonFocusNode.dispose();
|
||||
deleteButtonFocusNode.dispose();
|
||||
firstItemFocusNode.dispose();
|
||||
for (final node in gridItemFocusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
gridItemFocusNodes.clear();
|
||||
}
|
||||
|
||||
/// Get or create a focus node for a grid item at the given index
|
||||
FocusNode getGridItemFocusNode(int index) {
|
||||
return gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: 'detail_grid_item_$index'));
|
||||
}
|
||||
|
||||
/// Navigate from content to app bar
|
||||
void navigateToAppBar() {
|
||||
setState(() {
|
||||
isAppBarFocused = true;
|
||||
appBarFocusedButton = 0;
|
||||
});
|
||||
_focusAppBarButton(0);
|
||||
// Scroll to top to show the app bar
|
||||
scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
/// Handle BACK key from content - navigate to app bar and set flag to prevent PopScope exit
|
||||
void handleBackFromContent() {
|
||||
backHandledByKeyEvent = true;
|
||||
navigateToAppBar();
|
||||
}
|
||||
|
||||
/// Navigate focus from app bar down to the grid
|
||||
void navigateToGrid() {
|
||||
if (!hasItems) return;
|
||||
|
||||
// Check if we should restore focus to the last focused item
|
||||
final shouldRestoreFocus =
|
||||
lastFocusedIndex != null && lastFocusedContentVersion == contentVersion && lastFocusedIndex! >= 0;
|
||||
|
||||
final targetIndex = shouldRestoreFocus ? lastFocusedIndex! : 0;
|
||||
|
||||
setState(() {
|
||||
isAppBarFocused = false;
|
||||
});
|
||||
|
||||
if (targetIndex == 0) {
|
||||
firstItemFocusNode.requestFocus();
|
||||
} else {
|
||||
getGridItemFocusNode(targetIndex).requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle back navigation for PopScope. Returns true if should pop.
|
||||
bool handleBackNavigation() {
|
||||
// If BACK was already handled by a key event, don't pop
|
||||
if (backHandledByKeyEvent) {
|
||||
backHandledByKeyEvent = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAppBarFocused) {
|
||||
// Already on app bar, allow exit
|
||||
return true;
|
||||
} else {
|
||||
// Focus app bar first
|
||||
navigateToAppBar();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus a specific app bar button by index
|
||||
void _focusAppBarButton(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
playButtonFocusNode.requestFocus();
|
||||
break;
|
||||
case 1:
|
||||
shuffleButtonFocusNode.requestFocus();
|
||||
break;
|
||||
case 2:
|
||||
deleteButtonFocusNode.requestFocus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key events when app bar is focused
|
||||
KeyEventResult handleAppBarKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
final maxButton = appBarButtonCount - 1;
|
||||
|
||||
if (key.isLeftKey && appBarFocusedButton > 0) {
|
||||
setState(() => appBarFocusedButton--);
|
||||
_focusAppBarButton(appBarFocusedButton);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && appBarFocusedButton < maxButton) {
|
||||
setState(() => appBarFocusedButton++);
|
||||
_focusAppBarButton(appBarFocusedButton);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey) {
|
||||
// Return focus to grid
|
||||
navigateToGrid();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
final buttons = getAppBarButtons();
|
||||
if (appBarFocusedButton < buttons.length) {
|
||||
buttons[appBarFocusedButton].onPressed();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Already on app bar, exit the screen
|
||||
Navigator.pop(context);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Build focusable app bar action widgets
|
||||
List<Widget> buildFocusableAppBarActions() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
final buttons = getAppBarButtons();
|
||||
|
||||
return buttons.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final config = entry.value;
|
||||
final isFocused = isKeyboardMode && isAppBarFocused && appBarFocusedButton == index;
|
||||
|
||||
FocusNode focusNode;
|
||||
switch (index) {
|
||||
case 0:
|
||||
focusNode = playButtonFocusNode;
|
||||
break;
|
||||
case 1:
|
||||
focusNode = shuffleButtonFocusNode;
|
||||
break;
|
||||
case 2:
|
||||
focusNode = deleteButtonFocusNode;
|
||||
break;
|
||||
default:
|
||||
focusNode = FocusNode();
|
||||
}
|
||||
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: handleAppBarKeyEvent,
|
||||
child: Container(
|
||||
decoration: isFocused
|
||||
? BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20))
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: AppIcon(config.icon, fill: 1),
|
||||
tooltip: config.tooltip,
|
||||
onPressed: config.onPressed,
|
||||
color: config.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Auto-focus first item after load if in keyboard mode.
|
||||
/// Call this from loadItems() after items are loaded.
|
||||
void autoFocusFirstItemAfterLoad() {
|
||||
if (mounted && hasItems) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (InputModeTracker.isKeyboardMode(context)) {
|
||||
setState(() {
|
||||
isAppBarFocused = false;
|
||||
});
|
||||
firstItemFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first row of a grid with given column count
|
||||
bool isFirstRow(int index, int columnCount) {
|
||||
return index < columnCount;
|
||||
}
|
||||
|
||||
/// Track focus on a grid item. Call from onFocusChange of grid items.
|
||||
void trackGridItemFocus(int index, bool hasFocus) {
|
||||
if (hasFocus) {
|
||||
lastFocusedIndex = index;
|
||||
lastFocusedContentVersion = contentVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,20 +52,29 @@ class AdaptiveMediaGrid<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Extra top padding for focus decoration (scale + border extends beyond item bounds)
|
||||
static const double _focusDecorationPadding = 8.0;
|
||||
|
||||
/// Builds either a list or grid view based on the view mode
|
||||
Widget _buildItemsView(BuildContext context, ViewMode viewMode, LibraryDensity density) {
|
||||
final effectivePadding = padding ?? GridLayoutConstants.gridPadding;
|
||||
final basePadding = padding ?? GridLayoutConstants.gridPadding;
|
||||
// Add extra top padding for focus decoration of first row items
|
||||
final effectivePadding = basePadding.copyWith(top: basePadding.top + _focusDecorationPadding);
|
||||
final effectiveAspectRatio = childAspectRatio ?? GridLayoutConstants.posterAspectRatio;
|
||||
|
||||
if (viewMode == ViewMode.list) {
|
||||
return ListView.builder(
|
||||
padding: effectivePadding,
|
||||
// Allow focus decoration to render outside scroll bounds
|
||||
clipBehavior: Clip.none,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) => itemBuilder(context, items[index], index),
|
||||
);
|
||||
} else {
|
||||
return GridView.builder(
|
||||
padding: effectivePadding,
|
||||
// Allow focus decoration to render outside scroll bounds
|
||||
clipBehavior: Clip.none,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(context, density),
|
||||
childAspectRatio: effectiveAspectRatio,
|
||||
|
||||
@@ -143,12 +143,17 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<PlexHub, LibraryRe
|
||||
}
|
||||
}
|
||||
|
||||
// Extra top padding for focus decoration (scale + border extends beyond item bounds)
|
||||
static const double _focusDecorationPadding = 8.0;
|
||||
|
||||
@override
|
||||
Widget buildContent(List<PlexHub> items) {
|
||||
_ensureHubKeys(items.length);
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.fromLTRB(0, 8 + _focusDecorationPadding, 0, 8),
|
||||
// Allow focus decoration to render outside scroll bounds
|
||||
clipBehavior: Clip.none,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final hub = items[index];
|
||||
|
||||
@@ -524,7 +524,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
} else {
|
||||
await offlineWatch.markAsWatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey);
|
||||
}
|
||||
if (context.mounted) {
|
||||
if (mounted) {
|
||||
showAppSnackBar(
|
||||
context,
|
||||
isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline,
|
||||
@@ -542,7 +542,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
} else {
|
||||
await client.markAsWatched(metadata.ratingKey);
|
||||
}
|
||||
if (context.mounted) {
|
||||
if (mounted) {
|
||||
_watchStateChanged = true;
|
||||
showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched);
|
||||
// Update watch state without full rebuild
|
||||
@@ -550,7 +550,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../../services/play_queue_launcher.dart';
|
||||
import '../../models/plex_playlist.dart';
|
||||
@@ -8,8 +10,14 @@ import '../../models/plex_metadata.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../widgets/media_grid_sliver.dart';
|
||||
import '../../widgets/focusable_media_card.dart';
|
||||
import '../../widgets/media_grid_delegate.dart';
|
||||
import '../../utils/grid_size_calculator.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import 'playlist_item_card.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
import '../base_media_list_detail_screen.dart';
|
||||
@@ -38,11 +46,92 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
@override
|
||||
IconData get emptyIcon => Symbols.playlist_play_rounded;
|
||||
|
||||
// Scroll controller for scrolling to top when app bar is focused
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
// Focus management
|
||||
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
|
||||
final FocusNode _playButtonFocusNode = FocusNode(debugLabel: 'playlist_play');
|
||||
final FocusNode _shuffleButtonFocusNode = FocusNode(debugLabel: 'playlist_shuffle');
|
||||
final FocusNode _deleteButtonFocusNode = FocusNode(debugLabel: 'playlist_delete');
|
||||
|
||||
// Navigation state for regular (non-smart) playlists
|
||||
int _focusedIndex = 0;
|
||||
int _focusedColumn = 0; // 0=content, 1=drag handle, 2=remove button
|
||||
|
||||
// Move mode state
|
||||
int? _movingIndex;
|
||||
int? _originalIndex;
|
||||
List<PlexMetadata>? _originalOrder;
|
||||
|
||||
// Scroll-into-view keys
|
||||
final Map<int, GlobalKey> _itemKeys = {};
|
||||
|
||||
// App bar focus state
|
||||
bool _isAppBarFocused = false;
|
||||
int _appBarFocusedButton = 0; // 0=play, 1=shuffle, 2=delete
|
||||
|
||||
// Flag to prevent PopScope from exiting when BACK was handled by a key handler
|
||||
bool _backHandledByKeyEvent = false;
|
||||
|
||||
// Grid focus for smart playlists
|
||||
final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'playlist_first_item');
|
||||
final Map<int, FocusNode> _gridItemFocusNodes = {};
|
||||
int? _lastFocusedGridIndex;
|
||||
int _contentVersion = 0;
|
||||
int _lastFocusedContentVersion = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_listFocusNode.dispose();
|
||||
_playButtonFocusNode.dispose();
|
||||
_shuffleButtonFocusNode.dispose();
|
||||
_deleteButtonFocusNode.dispose();
|
||||
_firstItemFocusNode.dispose();
|
||||
for (final node in _gridItemFocusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
_gridItemFocusNodes.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Get or create a focus node for a grid item at the given index
|
||||
FocusNode _getGridItemFocusNode(int index) {
|
||||
return _gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: 'playlist_grid_item_$index'));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PlexMetadata>> fetchItems() async {
|
||||
return await client.getPlaylist(widget.playlist.ratingKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
// Increment content version when loading fresh content
|
||||
_contentVersion++;
|
||||
await super.loadItems();
|
||||
|
||||
// Auto-focus after load if in keyboard mode
|
||||
if (mounted && items.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (InputModeTracker.isKeyboardMode(context)) {
|
||||
setState(() {
|
||||
_isAppBarFocused = false;
|
||||
_focusedIndex = 0;
|
||||
_focusedColumn = 0;
|
||||
});
|
||||
if (widget.playlist.smart) {
|
||||
_firstItemFocusNode.requestFocus();
|
||||
} else {
|
||||
_listFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String getLoadSuccessMessage(int itemCount) {
|
||||
return 'Loaded $itemCount items for playlist: ${widget.playlist.title}';
|
||||
@@ -141,6 +230,72 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// Item is already at newIndex in the list
|
||||
final movedItem = items[newIndex];
|
||||
|
||||
// Check if item has playlistItemID (required for reordering)
|
||||
if (movedItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot persist move: item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
// Revert the UI change
|
||||
setState(() {
|
||||
final item = items.removeAt(newIndex);
|
||||
items.insert(originalIndex, item);
|
||||
_focusedIndex = originalIndex;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the "after" item ID based on where the item is now
|
||||
final int afterPlaylistItemId;
|
||||
if (newIndex == 0) {
|
||||
afterPlaylistItemId = 0; // Move to top
|
||||
} else {
|
||||
final afterItem = items[newIndex - 1];
|
||||
if (afterItem.playlistItemID == null) {
|
||||
appLogger.e('Cannot persist move: after item missing playlistItemID');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
// Revert the UI change
|
||||
setState(() {
|
||||
final item = items.removeAt(newIndex);
|
||||
items.insert(originalIndex, item);
|
||||
_focusedIndex = originalIndex;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
afterPlaylistItemId = afterItem.playlistItemID!;
|
||||
}
|
||||
|
||||
appLogger.d('Persisting move from $originalIndex to $newIndex (after ID: $afterPlaylistItemId)');
|
||||
|
||||
// Call API to persist the change (UI is already updated)
|
||||
final success = await client.movePlaylistItem(
|
||||
playlistId: widget.playlist.ratingKey,
|
||||
playlistItemId: movedItem.playlistItemID!,
|
||||
afterPlaylistItemId: afterPlaylistItemId,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
// Revert on failure
|
||||
appLogger.e('Failed to persist move, reverting UI');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final item = items.removeAt(newIndex);
|
||||
items.insert(originalIndex, item);
|
||||
_focusedIndex = originalIndex;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeItem(int index) async {
|
||||
final item = items[index];
|
||||
|
||||
@@ -201,57 +356,490 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
);
|
||||
}
|
||||
|
||||
/// Ensure the focused item is visible in the list
|
||||
void _ensureFocusedVisible() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final key = _itemKeys[_focusedIndex];
|
||||
final context = key?.currentContext;
|
||||
if (context != null) {
|
||||
Scrollable.ensureVisible(context, alignment: 0.25, duration: const Duration(milliseconds: 200));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle key events for list navigation
|
||||
KeyEventResult _handleListKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
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;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Cancel move mode, set flag to prevent PopScope exit
|
||||
_backHandledByKeyEvent = true;
|
||||
_cancelMoveMode();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else {
|
||||
// Navigation mode
|
||||
if (key.isUpKey) {
|
||||
if (_focusedIndex > 0) {
|
||||
setState(() {
|
||||
_focusedIndex--;
|
||||
_focusedColumn = 0; // Reset to row when changing rows
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
} else {
|
||||
// First item - navigate to app bar
|
||||
_navigateToAppBar();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && _focusedIndex < items.length - 1) {
|
||||
setState(() {
|
||||
_focusedIndex++;
|
||||
_focusedColumn = 0; // Reset to row when changing rows
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isLeftKey) {
|
||||
// Navigate left within columns
|
||||
if (_focusedColumn == 0 && widget.playlist.smart == false) {
|
||||
// Go to drag handle (column 1)
|
||||
setState(() => _focusedColumn = 1);
|
||||
return KeyEventResult.handled;
|
||||
} else if (_focusedColumn == 2) {
|
||||
// Go back to content
|
||||
setState(() => _focusedColumn = 0);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
if (key.isRightKey) {
|
||||
// Navigate right within columns
|
||||
if (_focusedColumn == 0) {
|
||||
// Go to remove button (column 2)
|
||||
setState(() => _focusedColumn = 2);
|
||||
return KeyEventResult.handled;
|
||||
} else if (_focusedColumn == 1) {
|
||||
// Go to content from drag handle
|
||||
setState(() => _focusedColumn = 0);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
if (_focusedColumn == 0) {
|
||||
// Play from this item
|
||||
_playFromItem(_focusedIndex);
|
||||
} else if (_focusedColumn == 1 && !widget.playlist.smart) {
|
||||
// Enter move mode
|
||||
setState(() {
|
||||
_movingIndex = _focusedIndex;
|
||||
_originalIndex = _focusedIndex;
|
||||
_originalOrder = List.from(items);
|
||||
});
|
||||
} else if (_focusedColumn == 2) {
|
||||
// Remove item
|
||||
_removeItem(_focusedIndex);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Navigate to app bar on BACK, set flag to prevent PopScope exit
|
||||
_handleBackFromContent();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Handle key events when app bar is focused
|
||||
KeyEventResult _handleAppBarKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
final hasDelete = !widget.playlist.smart;
|
||||
final maxButton = hasDelete ? 2 : 1;
|
||||
|
||||
if (key.isLeftKey && _appBarFocusedButton > 0) {
|
||||
setState(() => _appBarFocusedButton--);
|
||||
_focusAppBarButton(_appBarFocusedButton);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && _appBarFocusedButton < maxButton) {
|
||||
setState(() => _appBarFocusedButton++);
|
||||
_focusAppBarButton(_appBarFocusedButton);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey) {
|
||||
// Return focus to list/grid
|
||||
setState(() => _isAppBarFocused = false);
|
||||
if (items.isNotEmpty) {
|
||||
if (widget.playlist.smart) {
|
||||
_navigateToGrid();
|
||||
} else {
|
||||
_listFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
_triggerAppBarButton(_appBarFocusedButton);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Already on app bar, exit the screen
|
||||
Navigator.pop(context);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _focusAppBarButton(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
_playButtonFocusNode.requestFocus();
|
||||
break;
|
||||
case 1:
|
||||
_shuffleButtonFocusNode.requestFocus();
|
||||
break;
|
||||
case 2:
|
||||
_deleteButtonFocusNode.requestFocus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerAppBarButton(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
playItems();
|
||||
break;
|
||||
case 1:
|
||||
shufflePlayItems();
|
||||
break;
|
||||
case 2:
|
||||
if (!widget.playlist.smart) _deletePlaylist();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate focus from app bar down to the grid
|
||||
void _navigateToGrid() {
|
||||
if (items.isEmpty) return;
|
||||
|
||||
// Check if we should restore focus to the last focused item
|
||||
final shouldRestoreFocus =
|
||||
_lastFocusedGridIndex != null &&
|
||||
_lastFocusedContentVersion == _contentVersion &&
|
||||
_lastFocusedGridIndex! < items.length;
|
||||
|
||||
final targetIndex = shouldRestoreFocus ? _lastFocusedGridIndex! : 0;
|
||||
|
||||
if (targetIndex == 0) {
|
||||
_firstItemFocusNode.requestFocus();
|
||||
} else {
|
||||
_getGridItemFocusNode(targetIndex).requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate from grid to app bar
|
||||
void _navigateToAppBar() {
|
||||
setState(() {
|
||||
_isAppBarFocused = true;
|
||||
_appBarFocusedButton = 0;
|
||||
});
|
||||
_playButtonFocusNode.requestFocus();
|
||||
// Scroll to top to show the app bar
|
||||
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
/// Handle BACK key from grid/list - navigate to app bar and set flag to prevent PopScope exit
|
||||
void _handleBackFromContent() {
|
||||
_backHandledByKeyEvent = true;
|
||||
_navigateToAppBar();
|
||||
}
|
||||
|
||||
/// Handle back navigation for PopScope
|
||||
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;
|
||||
}
|
||||
|
||||
if (_isAppBarFocused) {
|
||||
// Already on app bar, allow exit
|
||||
return true;
|
||||
} else {
|
||||
// Focus app bar first
|
||||
_navigateToAppBar();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the number of columns in the current grid
|
||||
int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) {
|
||||
final screenWidth = MediaQuery.of(context).size.width - 16;
|
||||
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
|
||||
return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100);
|
||||
}
|
||||
|
||||
/// Check if the given index is in the first row of the grid
|
||||
bool _isFirstRow(int index, int columnCount) {
|
||||
return index < columnCount;
|
||||
}
|
||||
|
||||
/// Build focusable app bar actions
|
||||
List<Widget> _buildFocusableAppBarActions() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
Widget buildFocusableButton({
|
||||
required FocusNode focusNode,
|
||||
required int buttonIndex,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback onPressed,
|
||||
Color? color,
|
||||
}) {
|
||||
final isFocused = isKeyboardMode && _isAppBarFocused && _appBarFocusedButton == buttonIndex;
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
onKeyEvent: _handleAppBarKeyEvent,
|
||||
child: Container(
|
||||
decoration: isFocused
|
||||
? BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20))
|
||||
: null,
|
||||
child: IconButton(icon: AppIcon(icon, fill: 1), tooltip: tooltip, onPressed: onPressed, color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
if (items.isNotEmpty)
|
||||
buildFocusableButton(
|
||||
focusNode: _playButtonFocusNode,
|
||||
buttonIndex: 0,
|
||||
icon: Symbols.play_arrow_rounded,
|
||||
tooltip: t.discover.play,
|
||||
onPressed: playItems,
|
||||
),
|
||||
if (items.isNotEmpty)
|
||||
buildFocusableButton(
|
||||
focusNode: _shuffleButtonFocusNode,
|
||||
buttonIndex: 1,
|
||||
icon: Symbols.shuffle_rounded,
|
||||
tooltip: t.common.shuffle,
|
||||
onPressed: shufflePlayItems,
|
||||
),
|
||||
if (!widget.playlist.smart)
|
||||
buildFocusableButton(
|
||||
focusNode: _deleteButtonFocusNode,
|
||||
buttonIndex: 2,
|
||||
icon: Symbols.delete_rounded,
|
||||
tooltip: t.playlists.delete,
|
||||
onPressed: _deletePlaylist,
|
||||
color: Colors.red,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusedScrollScaffold(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.playlist.title, style: const TextStyle(fontSize: 16)),
|
||||
if (widget.playlist.smart)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Colors.blue[300]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
t.playlists.smartPlaylist,
|
||||
style: TextStyle(fontSize: 11, color: Colors.blue[300], fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) return;
|
||||
final shouldPop = _handleBackNavigation();
|
||||
if (shouldPop && mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.playlist.title, style: const TextStyle(fontSize: 16)),
|
||||
if (widget.playlist.smart)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Colors.blue[300]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
t.playlists.smartPlaylist,
|
||||
style: TextStyle(fontSize: 11, color: Colors.blue[300], fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: _buildFocusableAppBarActions(),
|
||||
),
|
||||
],
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
if (widget.playlist.smart)
|
||||
// Smart playlists: Use focusable grid view (cannot be reordered)
|
||||
_buildSmartPlaylistGrid(isKeyboardMode)
|
||||
else
|
||||
// Regular playlists: Use reorderable list view with focus
|
||||
_buildReorderableList(isKeyboardMode),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: buildAppBarActions(
|
||||
onDelete: widget.playlist.smart ? null : _deletePlaylist,
|
||||
deleteTooltip: t.playlists.delete,
|
||||
showDelete: !widget.playlist.smart,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a focusable grid for smart playlists
|
||||
Widget _buildSmartPlaylistGrid(bool isKeyboardMode) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
final columnCount = _getGridColumnCount(context, settingsProvider);
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final isFirstRow = _isFirstRow(index, columnCount);
|
||||
final focusNode = index == 0 ? _firstItemFocusNode : _getGridItemFocusNode(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.ratingKey),
|
||||
item: item,
|
||||
focusNode: focusNode,
|
||||
onRefresh: updateItem,
|
||||
onNavigateUp: isFirstRow ? _navigateToAppBar : null,
|
||||
onBack: _handleBackFromContent,
|
||||
onFocusChange: (hasFocus) {
|
||||
if (hasFocus) {
|
||||
_lastFocusedGridIndex = index;
|
||||
_lastFocusedContentVersion = _contentVersion;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a reorderable list for regular playlists with focus support
|
||||
Widget _buildReorderableList(bool isKeyboardMode) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Focus(
|
||||
autofocus: isKeyboardMode && items.isNotEmpty && !_isAppBarFocused,
|
||||
focusNode: _listFocusNode,
|
||||
onKeyEvent: _handleListKeyEvent,
|
||||
onFocusChange: (hasFocus) {
|
||||
// Rebuild when focus changes to update visual indicators
|
||||
if (hasFocus && mounted) {
|
||||
setState(() {
|
||||
_isAppBarFocused = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onReorder: _onReorder,
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
// Check keyboard mode directly to ensure we get latest value
|
||||
final inKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
final isFocused = inKeyboardMode && index == _focusedIndex && !_isAppBarFocused;
|
||||
final isMoving = index == _movingIndex;
|
||||
|
||||
_itemKeys.putIfAbsent(index, () => GlobalKey());
|
||||
|
||||
return PlaylistItemCard(
|
||||
key: _itemKeys[index] ?? ValueKey(item.playlistItemID ?? item.ratingKey),
|
||||
item: item,
|
||||
index: index,
|
||||
onRemove: () => _removeItem(index),
|
||||
onTap: () => _playFromItem(index),
|
||||
onRefresh: updateItem,
|
||||
canReorder: !widget.playlist.smart,
|
||||
isFocused: isFocused,
|
||||
focusedColumn: isFocused ? _focusedColumn : null,
|
||||
isMoving: isMoving,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
slivers: [
|
||||
...buildStateSlivers(),
|
||||
if (items.isNotEmpty)
|
||||
if (widget.playlist.smart)
|
||||
// Smart playlists: Use grid view (cannot be reordered)
|
||||
MediaGridSliver(items: items, onRefresh: updateItem)
|
||||
else
|
||||
// Regular playlists: Use reorderable list view
|
||||
SliverReorderableList(
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return PlaylistItemCard(
|
||||
key: ValueKey(item.playlistItemID ?? item.ratingKey),
|
||||
item: item,
|
||||
index: index,
|
||||
onRemove: () => _removeItem(index),
|
||||
onTap: () => _playFromItem(index),
|
||||
onRefresh: updateItem,
|
||||
canReorder: !widget.playlist.smart,
|
||||
);
|
||||
},
|
||||
itemCount: items.length,
|
||||
onReorder: _onReorder,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ class PlaylistItemCard extends StatefulWidget {
|
||||
final void Function(String ratingKey)? onRefresh;
|
||||
final bool canReorder; // Whether drag handle should be shown
|
||||
|
||||
// Focus state for keyboard/D-pad navigation
|
||||
final bool isFocused;
|
||||
final int? focusedColumn; // 0=row, 1=drag handle, 2=remove button
|
||||
final bool isMoving; // Whether this item is being moved/reordered
|
||||
|
||||
const PlaylistItemCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
@@ -28,6 +33,9 @@ class PlaylistItemCard extends StatefulWidget {
|
||||
this.onTap,
|
||||
this.onRefresh,
|
||||
this.canReorder = true,
|
||||
this.isFocused = false,
|
||||
this.focusedColumn,
|
||||
this.isMoving = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -40,6 +48,29 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.item;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Determine if row is focused (main content area)
|
||||
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
|
||||
|
||||
// Focus states for individual elements
|
||||
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
|
||||
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
|
||||
|
||||
// Determine card styling based on focus/move state
|
||||
Color? cardColor;
|
||||
ShapeBorder? cardShape;
|
||||
if (widget.isMoving) {
|
||||
cardColor = colorScheme.primaryContainer;
|
||||
} else if (isRowFocused) {
|
||||
// Row is focused - use visible border like FocusableWrapper
|
||||
cardColor = colorScheme.surfaceContainerHighest;
|
||||
cardShape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: colorScheme.primary, width: 2.5),
|
||||
);
|
||||
}
|
||||
|
||||
return MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
item: item,
|
||||
@@ -47,6 +78,8 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
onTap: widget.onTap,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
color: cardColor,
|
||||
shape: cardShape,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: Padding(
|
||||
@@ -60,9 +93,20 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
onLongPress: () {},
|
||||
child: ReorderableDragStartListener(
|
||||
index: widget.index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: AppIcon(Symbols.drag_indicator_rounded, fill: 1, color: Colors.grey),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
decoration: isDragHandleFocused
|
||||
? BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
)
|
||||
: null,
|
||||
child: AppIcon(
|
||||
widget.isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
|
||||
fill: 1,
|
||||
color: (widget.isMoving || isDragHandleFocused) ? colorScheme.primary : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -115,11 +159,19 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Remove button
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
|
||||
onPressed: widget.onRemove,
|
||||
tooltip: t.playlists.removeItem,
|
||||
color: Colors.grey[400],
|
||||
Container(
|
||||
decoration: isRemoveButtonFocused
|
||||
? BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
)
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
|
||||
onPressed: widget.onRemove,
|
||||
tooltip: t.playlists.removeItem,
|
||||
color: isRemoveButtonFocused ? colorScheme.primary : Colors.grey[400],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -1012,6 +1012,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (!mounted) return;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
_isExiting.value = true;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
@@ -1031,6 +1032,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
// Default behavior for hosts or non-session users
|
||||
if (!mounted) return;
|
||||
_isExiting.value = true;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ String formatDurationTimestamp(Duration duration) {
|
||||
final minutes = absoluteDuration.inMinutes.remainder(60);
|
||||
final seconds = absoluteDuration.inSeconds.remainder(60);
|
||||
|
||||
final result;
|
||||
final String result;
|
||||
|
||||
if (hours > 0) {
|
||||
result = '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
|
||||
/// A ListTile that accepts a FocusNode for keyboard/controller navigation.
|
||||
|
||||
+1
-1
@@ -678,7 +678,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.5"
|
||||
intl:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
|
||||
@@ -9,6 +9,7 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
intl: ^0.20.2
|
||||
dio: ^5.4.0
|
||||
json_annotation: ^4.8.1
|
||||
shared_preferences: ^2.2.2
|
||||
|
||||
Reference in New Issue
Block a user