From 23c4c400f02dece9f4b2a9f0fe07b26b8c86b70d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 16 Feb 2026 02:16:44 +0100 Subject: [PATCH] feat: focus navigation for movie detail sections close #464 --- lib/screens/livetv/tabs/whats_on_tab.dart | 19 +- lib/screens/media_detail_screen.dart | 1068 ++++++++++++--------- lib/utils/scroll_utils.dart | 25 + lib/widgets/hub_section.dart | 19 +- 4 files changed, 657 insertions(+), 474 deletions(-) create mode 100644 lib/utils/scroll_utils.dart diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 6e2b2d9c..61777700 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -22,6 +22,7 @@ import '../../../utils/plex_image_helper.dart'; import '../../../utils/provider_extensions.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/focus_builders.dart'; +import '../../../utils/scroll_utils.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart'; import '../../../widgets/plex_optimized_image.dart'; import '../live_tv_show_schedule_screen.dart'; @@ -346,17 +347,13 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> { } void _scrollToIndex(int index, {bool animate = true}) { - if (!_scrollController.hasClients || _itemExtent <= 0) return; - - final viewport = _scrollController.position.viewportDimension; - final targetCenter = _leadingPadding + (index * _itemExtent) + (_itemExtent / 2); - final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _scrollController.position.maxScrollExtent); - - if (animate) { - _scrollController.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } else { - _scrollController.jumpTo(desiredOffset); - } + scrollListToIndex( + _scrollController, + index, + itemExtent: _itemExtent, + leadingPadding: _leadingPadding, + animate: animate, + ); } KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index e5c66767..ed97b350 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -32,6 +32,7 @@ import '../providers/offline_watch_provider.dart'; import '../theme/mono_tokens.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; +import '../utils/scroll_utils.dart'; import '../utils/provider_extensions.dart'; import '../utils/dialogs.dart'; import '../utils/snackbar_helper.dart'; @@ -86,6 +87,17 @@ class _MediaDetailScreenState extends State with WatchStateAw int _focusedExtraIndex = 0; late final FocusNode _extrasFocusNode; final Map> _extraCardKeys = {}; + final _extrasSectionKey = GlobalKey(); + + // Locked focus pattern for overview + late final FocusNode _overviewFocusNode; + final _overviewSectionKey = GlobalKey(); + + // Locked focus pattern for cast + int _focusedCastIndex = 0; + late final FocusNode _castFocusNode; + final ScrollController _castScrollController = ScrollController(); + final _castSectionKey = GlobalKey(); String _toGlobalKey(String ratingKey, {String? serverId}) => '${serverId ?? widget.metadata.serverId ?? ''}:$ratingKey'; @@ -243,6 +255,8 @@ class _MediaDetailScreenState extends State with WatchStateAw _seasonsFocusNode = FocusNode(debugLabel: 'seasons_row'); _extrasFocusNode = FocusNode(debugLabel: 'extras_row'); _playButtonFocusNode = FocusNode(debugLabel: 'play_button'); + _overviewFocusNode = FocusNode(debugLabel: 'overview'); + _castFocusNode = FocusNode(debugLabel: 'cast_row'); _loadFullMetadata(); } @@ -260,6 +274,9 @@ class _MediaDetailScreenState extends State with WatchStateAw _seasonsFocusNode.dispose(); _extrasFocusNode.dispose(); _playButtonFocusNode.dispose(); + _overviewFocusNode.dispose(); + _castFocusNode.dispose(); + _castScrollController.dispose(); _selectKeyTimer?.cancel(); super.dispose(); } @@ -343,188 +360,149 @@ class _MediaDetailScreenState extends State with WatchStateAw final primaryTrailer = _getPrimaryTrailer(); - return Row( - children: [ - SizedBox( - height: 48, - child: FilledButton( - focusNode: _playButtonFocusNode, - autofocus: InputModeTracker.isKeyboardMode(context), - onPressed: onPlayPressed, - style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16)), - child: playButtonLabel.isNotEmpty - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - playButtonIcon, - const SizedBox(width: 8), - Text(playButtonLabel, style: const TextStyle(fontSize: 16)), - ], - ) - : playButtonIcon, - ), - ), - const SizedBox(width: 12), - // Trailer button (only if trailer is available) - if (primaryTrailer != null) ...[ - IconButton.filledTonal( - onPressed: () async { - await navigateToVideoPlayer(context, metadata: primaryTrailer); - }, - icon: const AppIcon(Symbols.theaters_rounded, fill: 1), - tooltip: t.tooltips.playTrailer, - iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + return Focus( + skipTraversal: true, + onKeyEvent: _handlePlayButtonKeyEvent, + child: Row( + children: [ + SizedBox( + height: 48, + child: FilledButton( + focusNode: _playButtonFocusNode, + autofocus: InputModeTracker.isKeyboardMode(context), + onPressed: onPlayPressed, + style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16)), + child: playButtonLabel.isNotEmpty + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + playButtonIcon, + const SizedBox(width: 8), + Text(playButtonLabel, style: const TextStyle(fontSize: 16)), + ], + ) + : playButtonIcon, + ), ), const SizedBox(width: 12), - ], - // Shuffle button (only for shows and seasons) - if (metadata.isShow || metadata.isSeason) ...[ - IconButton.filledTonal( - onPressed: () async { - await _handleShufflePlayWithQueue(context, metadata); - }, - icon: const AppIcon(Symbols.shuffle_rounded, fill: 1), - tooltip: t.tooltips.shufflePlay, - iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), - ), - const SizedBox(width: 12), - ], - // Download button (hide in offline mode - already downloaded) - if (!widget.isOffline) - Consumer( - builder: (context, downloadProvider, _) { - final globalKey = '${metadata.serverId}:${metadata.ratingKey}'; - final progress = downloadProvider.getProgress(globalKey); - final isQueueing = downloadProvider.isQueueing(globalKey); + // Trailer button (only if trailer is available) + if (primaryTrailer != null) ...[ + IconButton.filledTonal( + onPressed: () async { + await navigateToVideoPlayer(context, metadata: primaryTrailer); + }, + icon: const AppIcon(Symbols.theaters_rounded, fill: 1), + tooltip: t.tooltips.playTrailer, + iconSize: 20, + style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + ), + const SizedBox(width: 12), + ], + // Shuffle button (only for shows and seasons) + if (metadata.isShow || metadata.isSeason) ...[ + IconButton.filledTonal( + onPressed: () async { + await _handleShufflePlayWithQueue(context, metadata); + }, + icon: const AppIcon(Symbols.shuffle_rounded, fill: 1), + tooltip: t.tooltips.shufflePlay, + iconSize: 20, + style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + ), + const SizedBox(width: 12), + ], + // Download button (hide in offline mode - already downloaded) + if (!widget.isOffline) + Consumer( + builder: (context, downloadProvider, _) { + final globalKey = '${metadata.serverId}:${metadata.ratingKey}'; + final progress = downloadProvider.getProgress(globalKey); + final isQueueing = downloadProvider.isQueueing(globalKey); - // Debug logging - if (progress != null) { - appLogger.d('UI rebuilding for $globalKey: status=${progress.status}, progress=${progress.progress}%'); - } + // Debug logging + if (progress != null) { + appLogger.d( + 'UI rebuilding for $globalKey: status=${progress.status}, progress=${progress.progress}%', + ); + } - // State 1: Queueing (building download queue) - if (isQueueing) { - return IconButton.filledTonal( - onPressed: null, - icon: const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), - iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), - ); - } + // State 1: Queueing (building download queue) + if (isQueueing) { + return IconButton.filledTonal( + onPressed: null, + icon: const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + iconSize: 20, + style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + ); + } - // State 2: Queued (waiting to download) - if (progress?.status == DownloadStatus.queued) { - final currentFile = progress?.currentFile; - final tooltip = currentFile != null && currentFile.contains('episodes') - ? 'Queued $currentFile' - : 'Queued'; + // State 2: Queued (waiting to download) + if (progress?.status == DownloadStatus.queued) { + final currentFile = progress?.currentFile; + final tooltip = currentFile != null && currentFile.contains('episodes') + ? 'Queued $currentFile' + : 'Queued'; - return IconButton.filledTonal( - onPressed: null, - tooltip: tooltip, - icon: const AppIcon(Symbols.schedule_rounded, fill: 1), - iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), - ); - } + return IconButton.filledTonal( + onPressed: null, + tooltip: tooltip, + icon: const AppIcon(Symbols.schedule_rounded, fill: 1), + iconSize: 20, + style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + ); + } - // State 3: Downloading (active download) - if (progress?.status == DownloadStatus.downloading) { - // Show episode count in tooltip for shows/seasons - final currentFile = progress?.currentFile; - final tooltip = currentFile != null && currentFile.contains('episodes') - ? 'Downloading $currentFile' - : 'Downloading...'; + // State 3: Downloading (active download) + if (progress?.status == DownloadStatus.downloading) { + // Show episode count in tooltip for shows/seasons + final currentFile = progress?.currentFile; + final tooltip = currentFile != null && currentFile.contains('episodes') + ? 'Downloading $currentFile' + : 'Downloading...'; - return IconButton.filledTonal( - onPressed: null, - tooltip: tooltip, - icon: _buildRadialProgress(progress?.progressPercent), - iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), - ); - } + return IconButton.filledTonal( + onPressed: null, + tooltip: tooltip, + icon: _buildRadialProgress(progress?.progressPercent), + iconSize: 20, + style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + ); + } - // State 4: Paused (can resume) - if (progress?.status == DownloadStatus.paused) { - return IconButton.filledTonal( - onPressed: () async { - final client = _getClientForMetadata(context); - if (client == null) return; - await downloadProvider.resumeDownload(globalKey, client); - if (context.mounted) { - showAppSnackBar(context, 'Download resumed'); - } - }, - icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1), - tooltip: 'Resume download', - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - foregroundColor: Colors.amber, - ), - ); - } - - // State 5: Failed (can retry) - if (progress?.status == DownloadStatus.failed) { - return IconButton.filledTonal( - onPressed: () async { - final client = _getClientForMetadata(context); - if (client == null) return; - - // Delete failed download and retry - await downloadProvider.deleteDownload(globalKey); - try { - await downloadProvider.queueDownload(metadata, client); - - if (context.mounted) { - showSuccessSnackBar(context, t.downloads.downloadQueued); - } - } on CellularDownloadBlockedException { - if (context.mounted) { - showErrorSnackBar(context, t.settings.cellularDownloadBlocked); - } - } - }, - icon: const AppIcon(Symbols.error_outline_rounded, fill: 1), - tooltip: 'Retry download', - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - foregroundColor: Colors.red, - ), - ); - } - - // State 6: Cancelled (can delete or retry) - if (progress?.status == DownloadStatus.cancelled) { - return IconButton.filledTonal( - onPressed: () async { - // Show options: Delete or Retry - final retry = await showConfirmDialog( - context, - title: 'Cancelled Download', - message: 'This download was cancelled. What would you like to do?', - cancelText: t.common.delete, - confirmText: 'Retry', - ); - - if (!retry && context.mounted) { - await downloadProvider.deleteDownload(globalKey); - if (context.mounted) { - showSuccessSnackBar(context, t.downloads.downloadDeleted); - } - } else if (retry && context.mounted) { + // State 4: Paused (can resume) + if (progress?.status == DownloadStatus.paused) { + return IconButton.filledTonal( + onPressed: () async { final client = _getClientForMetadata(context); if (client == null) return; + await downloadProvider.resumeDownload(globalKey, client); + if (context.mounted) { + showAppSnackBar(context, 'Download resumed'); + } + }, + icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1), + tooltip: 'Resume download', + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + foregroundColor: Colors.amber, + ), + ); + } + + // State 5: Failed (can retry) + if (progress?.status == DownloadStatus.failed) { + return IconButton.filledTonal( + onPressed: () async { + final client = _getClientForMetadata(context); + if (client == null) return; + + // Delete failed download and retry await downloadProvider.deleteDownload(globalKey); try { await downloadProvider.queueDownload(metadata, client); + if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadQueued); } @@ -533,150 +511,195 @@ class _MediaDetailScreenState extends State with WatchStateAw showErrorSnackBar(context, t.settings.cellularDownloadBlocked); } } - } - }, - icon: const AppIcon(Symbols.cancel_rounded, fill: 1), - tooltip: 'Cancelled download', - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - foregroundColor: Colors.grey, - ), - ); - } + }, + icon: const AppIcon(Symbols.error_outline_rounded, fill: 1), + tooltip: 'Retry download', + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + foregroundColor: Colors.red, + ), + ); + } - // State 7: Partial Download (some episodes downloaded, not all) - if (progress?.status == DownloadStatus.partial) { - final currentFile = progress?.currentFile; - final tooltip = currentFile != null - ? 'Downloaded $currentFile - Click to complete' - : 'Partially downloaded - Click to complete'; + // State 6: Cancelled (can delete or retry) + if (progress?.status == DownloadStatus.cancelled) { + return IconButton.filledTonal( + onPressed: () async { + // Show options: Delete or Retry + final retry = await showConfirmDialog( + context, + title: 'Cancelled Download', + message: 'This download was cancelled. What would you like to do?', + cancelText: t.common.delete, + confirmText: 'Retry', + ); + if (!retry && context.mounted) { + await downloadProvider.deleteDownload(globalKey); + if (context.mounted) { + showSuccessSnackBar(context, t.downloads.downloadDeleted); + } + } else if (retry && context.mounted) { + final client = _getClientForMetadata(context); + if (client == null) return; + await downloadProvider.deleteDownload(globalKey); + try { + await downloadProvider.queueDownload(metadata, client); + if (context.mounted) { + showSuccessSnackBar(context, t.downloads.downloadQueued); + } + } on CellularDownloadBlockedException { + if (context.mounted) { + showErrorSnackBar(context, t.settings.cellularDownloadBlocked); + } + } + } + }, + icon: const AppIcon(Symbols.cancel_rounded, fill: 1), + tooltip: 'Cancelled download', + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + foregroundColor: Colors.grey, + ), + ); + } + + // State 7: Partial Download (some episodes downloaded, not all) + if (progress?.status == DownloadStatus.partial) { + final currentFile = progress?.currentFile; + final tooltip = currentFile != null + ? 'Downloaded $currentFile - Click to complete' + : 'Partially downloaded - Click to complete'; + + return IconButton.filledTonal( + onPressed: () async { + final client = _getClientForMetadata(context); + if (client == null) return; + + // Queue only the missing episodes + final count = await downloadProvider.queueMissingEpisodes(metadata, client); + + if (context.mounted) { + final message = count > 0 + ? t.downloads.episodesQueued(count: count) + : 'All episodes already downloaded'; + showAppSnackBar(context, message); + } + }, + tooltip: tooltip, + icon: const AppIcon(Symbols.downloading_rounded, fill: 1), + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + foregroundColor: Colors.orange, + ), + ); + } + + // State 8: Downloaded/Completed (can delete) + if (downloadProvider.isDownloaded(globalKey)) { + return IconButton.filledTonal( + onPressed: () async { + // Show delete download confirmation + final confirmed = await showDeleteConfirmation( + context, + title: t.downloads.deleteDownload, + message: t.downloads.deleteConfirm(title: metadata.title), + ); + + if (confirmed && context.mounted) { + await downloadProvider.deleteDownload(globalKey); + if (context.mounted) { + showSuccessSnackBar(context, t.downloads.downloadDeleted); + } + } + }, + icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1), + tooltip: t.downloads.deleteDownload, + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + foregroundColor: Colors.green, + ), + ); + } + + // State 9: Not downloaded (default - can download) return IconButton.filledTonal( onPressed: () async { final client = _getClientForMetadata(context); if (client == null) return; - - // Queue only the missing episodes - final count = await downloadProvider.queueMissingEpisodes(metadata, client); - + final count = await downloadProvider.queueDownload(metadata, client); if (context.mounted) { - final message = count > 0 - ? t.downloads.episodesQueued(count: count) - : 'All episodes already downloaded'; - showAppSnackBar(context, message); + final message = count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued; + showSuccessSnackBar(context, message); } }, - tooltip: tooltip, - icon: const AppIcon(Symbols.downloading_rounded, fill: 1), + icon: const AppIcon(Symbols.download_rounded, fill: 1), + tooltip: t.downloads.downloadNow, iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - foregroundColor: Colors.orange, - ), + style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), ); - } - - // State 8: Downloaded/Completed (can delete) - if (downloadProvider.isDownloaded(globalKey)) { - return IconButton.filledTonal( - onPressed: () async { - // Show delete download confirmation - final confirmed = await showDeleteConfirmation( + }, + ), + const SizedBox(width: 12), + // Mark as watched/unwatched toggle (works offline too) + IconButton.filledTonal( + onPressed: () async { + try { + final isWatched = metadata.isWatched; + if (widget.isOffline) { + // Offline mode: queue action for later sync + final offlineWatch = context.read(); + if (isWatched) { + await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); + } else { + await offlineWatch.markAsWatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); + } + if (mounted) { + showAppSnackBar( context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: metadata.title), + isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline, ); - - if (confirmed && context.mounted) { - await downloadProvider.deleteDownload(globalKey); - if (context.mounted) { - showSuccessSnackBar(context, t.downloads.downloadDeleted); - } - } - }, - icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1), - tooltip: t.downloads.deleteDownload, - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - foregroundColor: Colors.green, - ), - ); - } - - // State 9: Not downloaded (default - can download) - return IconButton.filledTonal( - onPressed: () async { + // Refresh offline OnDeck + _loadOfflineOnDeckEpisode(); + } + } else { + // Online mode: send to server final client = _getClientForMetadata(context); if (client == null) return; - final count = await downloadProvider.queueDownload(metadata, client); - if (context.mounted) { - final message = count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued; - showSuccessSnackBar(context, message); - } - }, - icon: const AppIcon(Symbols.download_rounded, fill: 1), - tooltip: t.downloads.downloadNow, - iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), - ); - }, - ), - const SizedBox(width: 12), - // Mark as watched/unwatched toggle (works offline too) - IconButton.filledTonal( - onPressed: () async { - try { - final isWatched = metadata.isWatched; - if (widget.isOffline) { - // Offline mode: queue action for later sync - final offlineWatch = context.read(); - if (isWatched) { - await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); - } else { - await offlineWatch.markAsWatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); - } - if (mounted) { - showAppSnackBar( - context, - isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline, - ); - // Refresh offline OnDeck - _loadOfflineOnDeckEpisode(); - } - } else { - // Online mode: send to server - final client = _getClientForMetadata(context); - if (client == null) return; - if (isWatched) { - await client.markAsUnwatched(metadata.ratingKey); - } else { - await client.markAsWatched(metadata.ratingKey); + if (isWatched) { + await client.markAsUnwatched(metadata.ratingKey); + } else { + await client.markAsWatched(metadata.ratingKey); + } + if (mounted) { + _watchStateChanged = true; + showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched); + // Update watch state without full rebuild + _updateWatchState(); + } } + } catch (e) { if (mounted) { - _watchStateChanged = true; - showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched); - // Update watch state without full rebuild - _updateWatchState(); + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } - } catch (e) { - if (mounted) { - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } - } - }, - icon: AppIcon(metadata.isWatched ? Symbols.remove_done_rounded : Symbols.check_rounded, fill: 1), - tooltip: metadata.isWatched ? t.tooltips.markAsUnwatched : t.tooltips.markAsWatched, - iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), - ), - ], + }, + icon: AppIcon(metadata.isWatched ? Symbols.remove_done_rounded : Symbols.check_rounded, fill: 1), + tooltip: metadata.isWatched ? t.tooltips.markAsUnwatched : t.tooltips.markAsWatched, + iconSize: 20, + style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + ), + ], + ), ); } @@ -998,36 +1021,59 @@ class _MediaDetailScreenState extends State with WatchStateAw } } - /// Scroll season list to center the item at the given index - void _scrollSeasonToIndex(int index, {bool animate = true}) { - if (!_seasonsScrollController.hasClients) return; + /// Scroll the main CustomScrollView so the section with the given key is visible + void _scrollSectionIntoView(GlobalKey key) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = key.currentContext; + if (ctx != null) { + Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + } + }); + } + /// Intercept DOWN from the play button row to focus the first available section + KeyEventResult _handlePlayButtonKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + if (!event.isActionable) return KeyEventResult.ignored; + if (!key.isDownKey) return KeyEventResult.ignored; + + final metadata = _fullMetadata ?? widget.metadata; + + // For shows, go to seasons first + if (metadata.isShow && _seasons.isNotEmpty) { + _seasonsFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // For movies (or shows with no seasons): overview → cast → extras + if (metadata.summary != null) { + _overviewFocusNode.requestFocus(); + _scrollSectionIntoView(_overviewSectionKey); + return KeyEventResult.handled; + } + + if (metadata.role != null && metadata.role!.isNotEmpty) { + _castFocusNode.requestFocus(); + _scrollSectionIntoView(_castSectionKey); + return KeyEventResult.handled; + } + + if (_extras != null && _extras!.isNotEmpty) { + _extrasFocusNode.requestFocus(); + _scrollSectionIntoView(_extrasSectionKey); + return KeyEventResult.handled; + } + + return KeyEventResult.handled; // consume to prevent unwanted traversal + } + + /// Get the responsive card width used by seasons/extras/cast rows + double _getResponsiveCardWidth() { final screenWidth = MediaQuery.of(context).size.width; - double cardWidth; - if (screenWidth >= 1400) { - cardWidth = 220.0; - } else if (screenWidth >= 900) { - cardWidth = 200.0; - } else if (screenWidth >= 700) { - cardWidth = 190.0; - } else { - cardWidth = 160.0; - } - final itemExtent = cardWidth + 4; // card + padding - - final viewport = _seasonsScrollController.position.viewportDimension; - final targetCenter = 12 + (index * itemExtent) + (itemExtent / 2); // 12 = leading padding - final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _seasonsScrollController.position.maxScrollExtent); - - if (animate) { - _seasonsScrollController.animateTo( - desiredOffset, - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - ); - } else { - _seasonsScrollController.jumpTo(desiredOffset); - } + if (screenWidth >= 1400) return 220.0; + if (screenWidth >= 900) return 200.0; + if (screenWidth >= 700) return 190.0; + return 160.0; } /// Handle key events for the seasons row (locked focus pattern) @@ -1083,7 +1129,7 @@ class _MediaDetailScreenState extends State with WatchStateAw setState(() { _focusedSeasonIndex--; }); - _scrollSeasonToIndex(_focusedSeasonIndex); + scrollListToIndex(_seasonsScrollController, _focusedSeasonIndex, itemExtent: _getResponsiveCardWidth() + 4); } return KeyEventResult.handled; } @@ -1094,7 +1140,7 @@ class _MediaDetailScreenState extends State with WatchStateAw setState(() { _focusedSeasonIndex++; }); - _scrollSeasonToIndex(_focusedSeasonIndex); + scrollListToIndex(_seasonsScrollController, _focusedSeasonIndex, itemExtent: _getResponsiveCardWidth() + 4); } return KeyEventResult.handled; } @@ -1106,10 +1152,18 @@ class _MediaDetailScreenState extends State with WatchStateAw return KeyEventResult.handled; } - // DOWN: move to extras if available, otherwise consume + // DOWN: overview → cast → extras (first that exists) if (key.isDownKey) { - if (_extras != null && _extras!.isNotEmpty) { + final metadata = _fullMetadata ?? widget.metadata; + if (metadata.summary != null) { + _overviewFocusNode.requestFocus(); + _scrollSectionIntoView(_overviewSectionKey); + } else if (metadata.role != null && metadata.role!.isNotEmpty) { + _castFocusNode.requestFocus(); + _scrollSectionIntoView(_castSectionKey); + } else if (_extras != null && _extras!.isNotEmpty) { _extrasFocusNode.requestFocus(); + _scrollSectionIntoView(_extrasSectionKey); } return KeyEventResult.handled; } @@ -1117,20 +1171,49 @@ class _MediaDetailScreenState extends State with WatchStateAw return KeyEventResult.ignored; } + /// Handle key events for the overview section + KeyEventResult _handleOverviewKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + if (key.isBackKey) return KeyEventResult.ignored; + if (!event.isActionable) return KeyEventResult.ignored; + + final metadata = _fullMetadata ?? widget.metadata; + + // UP: seasons (if show) or play button + if (key.isUpKey) { + if (metadata.isShow && _seasons.isNotEmpty) { + _seasonsFocusNode.requestFocus(); + } else { + _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + _playButtonFocusNode.requestFocus(); + } + return KeyEventResult.handled; + } + + // DOWN: cast → extras → consume + if (key.isDownKey) { + if (metadata.role != null && metadata.role!.isNotEmpty) { + _castFocusNode.requestFocus(); + _scrollSectionIntoView(_castSectionKey); + } else if (_extras != null && _extras!.isNotEmpty) { + _extrasFocusNode.requestFocus(); + _scrollSectionIntoView(_extrasSectionKey); + } + return KeyEventResult.handled; + } + + // LEFT/RIGHT/SELECT: consume to prevent unwanted traversal + if (key.isLeftKey || key.isRightKey || key.isSelectKey) { + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + /// Build horizontal seasons list for larger screens (>=600px) /// Uses locked focus pattern for D-pad centered scrolling Widget _buildHorizontalSeasons() { - final screenWidth = MediaQuery.of(context).size.width; - double cardWidth; - if (screenWidth >= 1400) { - cardWidth = 220.0; - } else if (screenWidth >= 900) { - cardWidth = 200.0; - } else if (screenWidth >= 700) { - cardWidth = 190.0; - } else { - cardWidth = 160.0; - } + final cardWidth = _getResponsiveCardWidth(); final posterHeight = (cardWidth - 16) * 1.5; final containerHeight = posterHeight + 66; @@ -1188,37 +1271,6 @@ class _MediaDetailScreenState extends State with WatchStateAw ); } - /// Scroll the extras list to center the given index - void _scrollExtraToIndex(int index, {bool animate = true}) { - if (!_extrasScrollController.hasClients) return; - - final screenWidth = MediaQuery.of(context).size.width; - double cardWidth; - if (screenWidth >= 1400) { - cardWidth = 220.0; - } else if (screenWidth >= 900) { - cardWidth = 200.0; - } else if (screenWidth >= 700) { - cardWidth = 190.0; - } else { - cardWidth = 160.0; - } - final itemExtent = cardWidth + 4; // card + padding - - final viewport = _extrasScrollController.position.viewportDimension; - final targetCenter = 12 + (index * itemExtent) + (itemExtent / 2); // 12 = leading padding - final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _extrasScrollController.position.maxScrollExtent); - - if (animate) { - _extrasScrollController.animateTo( - desiredOffset, - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - ); - } else { - _extrasScrollController.jumpTo(desiredOffset); - } - } /// Handle key events for the extras row (locked focus pattern) KeyEventResult _handleExtrasKeyEvent(FocusNode _, KeyEvent event) { @@ -1264,7 +1316,7 @@ class _MediaDetailScreenState extends State with WatchStateAw if (key.isLeftKey) { if (_focusedExtraIndex > 0) { setState(() => _focusedExtraIndex--); - _scrollExtraToIndex(_focusedExtraIndex); + scrollListToIndex(_extrasScrollController, _focusedExtraIndex, itemExtent: _getResponsiveCardWidth() + 4); } return KeyEventResult.handled; } @@ -1273,14 +1325,21 @@ class _MediaDetailScreenState extends State with WatchStateAw if (key.isRightKey) { if (_focusedExtraIndex < _extras!.length - 1) { setState(() => _focusedExtraIndex++); - _scrollExtraToIndex(_focusedExtraIndex); + scrollListToIndex(_extrasScrollController, _focusedExtraIndex, itemExtent: _getResponsiveCardWidth() + 4); } return KeyEventResult.handled; } - // UP: move to seasons (if show) or play button + // UP: cast → overview → seasons → play button (first that exists) if (key.isUpKey) { - if (_seasons.isNotEmpty) { + final metadata = _fullMetadata ?? widget.metadata; + if (metadata.role != null && metadata.role!.isNotEmpty) { + _castFocusNode.requestFocus(); + _scrollSectionIntoView(_castSectionKey); + } else if (metadata.summary != null) { + _overviewFocusNode.requestFocus(); + _scrollSectionIntoView(_overviewSectionKey); + } else if (_seasons.isNotEmpty) { _seasonsFocusNode.requestFocus(); } else { _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); @@ -1297,6 +1356,64 @@ class _MediaDetailScreenState extends State with WatchStateAw return KeyEventResult.ignored; } + /// Handle key events for the cast row (locked focus pattern) + KeyEventResult _handleCastKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + if (key.isBackKey) return KeyEventResult.ignored; + if (!event.isActionable) return KeyEventResult.ignored; + + final metadata = _fullMetadata ?? widget.metadata; + final roleCount = metadata.role?.length ?? 0; + + // LEFT: previous cast member + if (key.isLeftKey) { + if (_focusedCastIndex > 0) { + setState(() => _focusedCastIndex--); + scrollListToIndex(_castScrollController, _focusedCastIndex, itemExtent: 120.0 + 4); + } + return KeyEventResult.handled; + } + + // RIGHT: next cast member + if (key.isRightKey) { + if (_focusedCastIndex < roleCount - 1) { + setState(() => _focusedCastIndex++); + scrollListToIndex(_castScrollController, _focusedCastIndex, itemExtent: 120.0 + 4); + } + return KeyEventResult.handled; + } + + // UP: overview → seasons (if show) → play button + if (key.isUpKey) { + if (metadata.summary != null) { + _overviewFocusNode.requestFocus(); + _scrollSectionIntoView(_overviewSectionKey); + } else if (metadata.isShow && _seasons.isNotEmpty) { + _seasonsFocusNode.requestFocus(); + } else { + _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + _playButtonFocusNode.requestFocus(); + } + return KeyEventResult.handled; + } + + // DOWN: extras (if available) → consume + if (key.isDownKey) { + if (_extras != null && _extras!.isNotEmpty) { + _extrasFocusNode.requestFocus(); + _scrollSectionIntoView(_extrasSectionKey); + } + return KeyEventResult.handled; + } + + // SELECT: consume (cast is informational) + if (key.isSelectKey) { + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + /// Build vertical seasons list for smaller screens (<600px) Widget _buildVerticalSeasons() { return ListView.separated( @@ -1798,18 +1915,45 @@ class _MediaDetailScreenState extends State with WatchStateAw // Summary if (metadata.summary != null) ...[ Text( + key: _overviewSectionKey, t.discover.overview, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), - if (isTv) - Text(metadata.summary!, style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6)) - else - CollapsibleText( - text: metadata.summary!, - maxLines: isMobile ? 6 : 4, - style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6), + Focus( + focusNode: _overviewFocusNode, + onKeyEvent: _handleOverviewKeyEvent, + onFocusChange: (_) => setState(() {}), + child: Builder( + builder: (context) { + final showFocus = + _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: showFocus + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.5) + : Colors.transparent, + width: 2, + ), + ), + child: isTv + ? Text( + metadata.summary!, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6), + ) + : CollapsibleText( + text: metadata.summary!, + maxLines: isMobile ? 6 : 4, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6), + ), + ); + }, ), + ), const SizedBox(height: 24), ], @@ -1844,79 +1988,19 @@ class _MediaDetailScreenState extends State with WatchStateAw // Cast if (metadata.role != null && metadata.role!.isNotEmpty) ...[ Text( + key: _castSectionKey, t.discover.cast, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), - SizedBox( - height: 220, - child: HorizontalScrollWithArrows( - builder: (scrollController) => ListView.separated( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: metadata.role!.length, - separatorBuilder: (context, index) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final actor = metadata.role![index]; - return SizedBox( - width: 120, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: PlexOptimizedImage( - client: _getClientForMetadata(context), - imagePath: actor.thumb, - width: 120, - height: 120, - fit: BoxFit.cover, - imageType: ImageType.avatar, - fallbackIcon: Symbols.person_rounded, - ), - ), - const SizedBox(height: 8), - SizedBox( - height: 84, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - actor.tag, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (actor.role != null) ...[ - const SizedBox(height: 2), - Text( - actor.role!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ], - ), - ); - }, - ), - ), - ), + _buildCastSection(metadata), const SizedBox(height: 24), ], // Trailers & Extras Section if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[ Text( + key: _extrasSectionKey, t.discover.extras, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), @@ -2024,19 +2108,99 @@ class _MediaDetailScreenState extends State with WatchStateAw } } - /// Build horizontal extras list using the same locked focus pattern as seasons + /// Build the cast section with locked focus pattern for D-pad navigation + /// Uses same layout pattern as seasons/extras (ListView.builder + Padding(horizontal: 2)) + Widget _buildCastSection(PlexMetadata metadata) { + const cardWidth = 120.0; + const innerPadding = 6.0; + // image + inner padding + text area + outer list padding + focus scale headroom + const containerHeight = 120.0 + innerPadding * 2 + 66 + 16; + + final hasFocus = _castFocusNode.hasFocus; + + return Focus( + focusNode: _castFocusNode, + onKeyEvent: _handleCastKeyEvent, + onFocusChange: (_) => setState(() {}), + child: SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + controller: _castScrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 12), + itemCount: metadata.role!.length, + itemBuilder: (context, index) { + final actor = metadata.role![index]; + final isFocused = hasFocus && index == _focusedCastIndex; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + borderRadius: tokens(context).radiusSm, + child: Padding( + padding: const EdgeInsets.all(innerPadding), + child: SizedBox( + width: cardWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: PlexOptimizedImage( + client: _getClientForMetadata(context), + imagePath: actor.thumb, + width: 120, + height: 120, + fit: BoxFit.cover, + imageType: ImageType.avatar, + fallbackIcon: Symbols.person_rounded, + ), + ), + const SizedBox(height: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + actor.tag, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (actor.role != null) ...[ + const SizedBox(height: 2), + Text( + actor.role!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + Widget _buildExtrasSection() { - final screenWidth = MediaQuery.of(context).size.width; - double cardWidth; - if (screenWidth >= 1400) { - cardWidth = 220.0; - } else if (screenWidth >= 900) { - cardWidth = 200.0; - } else if (screenWidth >= 700) { - cardWidth = 190.0; - } else { - cardWidth = 160.0; - } + final cardWidth = _getResponsiveCardWidth(); // 16:9 aspect ratio for clip thumbnails (cardWidth includes 8px padding on each side) final posterHeight = (cardWidth - 16) * (9 / 16); final containerHeight = posterHeight + 66; diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart new file mode 100644 index 00000000..da7f418d --- /dev/null +++ b/lib/utils/scroll_utils.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +/// Scroll a horizontal list to center the item at the given index. +/// +/// Assumes items are laid out with [leadingPadding] before the first item, +/// and each item occupies [itemExtent] pixels (including per-item padding). +void scrollListToIndex( + ScrollController controller, + int index, { + required double itemExtent, + double leadingPadding = 12.0, + bool animate = true, +}) { + if (!controller.hasClients || itemExtent <= 0) return; + + final viewport = controller.position.viewportDimension; + final targetCenter = leadingPadding + (index * itemExtent) + (itemExtent / 2); + final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, controller.position.maxScrollExtent); + + if (animate) { + controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + } else { + controller.jumpTo(desiredOffset); + } +} diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index d2c9cf5c..aa678cc4 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -17,6 +17,7 @@ import '../screens/hub_detail_screen.dart'; import '../utils/media_navigation_helper.dart'; import 'focus_builders.dart'; import 'media_card.dart'; +import '../utils/scroll_utils.dart'; import 'horizontal_scroll_with_arrows.dart'; import '../i18n/strings.g.dart'; @@ -169,17 +170,13 @@ class HubSectionState extends State { /// Scroll to center the item at the given index void _scrollToIndex(int index, {bool animate = true}) { - if (!_scrollController.hasClients || _itemExtent <= 0) return; - - final viewport = _scrollController.position.viewportDimension; - final targetCenter = _leadingPadding + (index * _itemExtent) + (_itemExtent / 2); - final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _scrollController.position.maxScrollExtent); - - if (animate) { - _scrollController.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } else { - _scrollController.jumpTo(desiredOffset); - } + scrollListToIndex( + _scrollController, + index, + itemExtent: _itemExtent, + leadingPadding: _leadingPadding, + animate: animate, + ); } /// Handle ALL key events at the hub level