diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index f28e0a86..62df8864 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -1,13 +1,19 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'dpad_navigator.dart'; import 'key_event_utils.dart'; /// Callbacks for chip key event handling. class ChipKeyCallbacks { - /// Called when SELECT key is pressed. + /// Called when SELECT key is pressed (short press when [onLongPress] is set). final VoidCallback? onSelect; + /// Called when SELECT key is held for 500ms. + final VoidCallback? onLongPress; + /// Called when DOWN arrow is pressed. final VoidCallback? onNavigateDown; @@ -25,6 +31,7 @@ class ChipKeyCallbacks { const ChipKeyCallbacks({ this.onSelect, + this.onLongPress, this.onNavigateDown, this.onNavigateUp, this.onNavigateLeft, @@ -53,6 +60,8 @@ class ChipKeyCallbacks { mixin FocusableChipStateMixin on State { FocusNode? _internalFocusNode; bool _isFocused = false; + Timer? _longPressTimer; + bool _isSelectKeyDown = false; /// Override to return the widget's optional external focus node. FocusNode? get widgetFocusNode; @@ -85,6 +94,7 @@ mixin FocusableChipStateMixin on State { void disposeFocusNode() { focusNode.removeListener(_onFocusChange); _internalFocusNode?.dispose(); + _longPressTimer?.cancel(); } void _onFocusChange() { @@ -96,7 +106,7 @@ mixin FocusableChipStateMixin on State { /// Shared key event handler for chip widgets. /// /// Handles common key patterns: - /// - SELECT key -> onSelect + /// - SELECT key -> onSelect (short press) / onLongPress (hold 500ms) /// - Arrow keys -> navigation callbacks /// - BACK key -> onBack /// @@ -112,16 +122,53 @@ mixin FocusableChipStateMixin on State { } } - if (!event.isActionable) { - return KeyEventResult.ignored; + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; } - // SELECT key activates the chip - if (key.isSelectKey && callbacks.onSelect != null) { - callbacks.onSelect!(); + // SELECT key with long press support + if (key.isSelectKey) { + if (callbacks.onLongPress != null) { + if (event is KeyDownEvent) { + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTimer?.cancel(); + _longPressTimer = Timer(const Duration(milliseconds: 500), () { + if (mounted) { + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + callbacks.onLongPress?.call(); + } + }); + } + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + return KeyEventResult.handled; + } else if (event is KeyUpEvent) { + final timerWasActive = _longPressTimer?.isActive ?? false; + _longPressTimer?.cancel(); + if (timerWasActive && _isSelectKeyDown) { + callbacks.onSelect?.call(); + } + _isSelectKeyDown = false; + return KeyEventResult.handled; + } + } else if (event.isActionable && callbacks.onSelect != null) { + callbacks.onSelect!(); + return KeyEventResult.handled; + } + } + + // Context menu key triggers long press directly + if (event.isActionable && key.isContextMenuKey && callbacks.onLongPress != null) { + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + callbacks.onLongPress!(); return KeyEventResult.handled; } + if (!event.isActionable) { + return KeyEventResult.ignored; + } + // LEFT arrow - call callback if provided, otherwise propagate to parent if (key.isLeftKey) { if (callbacks.onNavigateLeft != null) { diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 8d116bb1..98a94721 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -240,7 +240,15 @@ class _FocusableWrapperState extends State with SingleTickerPr final renderObject = context.findRenderObject(); if (renderObject == null) return; - final scrollable = Scrollable.maybeOf(context); + // Find the nearest scrollable that actually has scroll range. + // Skip inner scrollables with no extent (e.g. shrinkWrap ListView + // with NeverScrollableScrollPhysics inside an outer scroll view). + var scrollable = Scrollable.maybeOf(context); + while (scrollable != null) { + final pos = scrollable.position; + if (pos.maxScrollExtent > pos.minScrollExtent) break; + scrollable = Scrollable.maybeOf(scrollable.context); + } if (scrollable == null) return; final viewport = scrollable.context.findRenderObject() as RenderBox?; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index fa3da1d4..a53d3d49 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -52,12 +52,17 @@ import '../mixins/server_bound_media_mixin.dart'; import '../utils/watch_state_notifier.dart'; import '../utils/deletion_notifier.dart'; import '../widgets/episode_card.dart'; +import '../widgets/focusable_tab_chip.dart'; class MediaDetailScreen extends StatefulWidget { final PlexMetadata metadata; final bool isOffline; - const MediaDetailScreen({super.key, required this.metadata, this.isOffline = false}); + /// If provided, auto-selects this season index when the screen loads. + /// Used when navigating to a show from a season context. + final int? initialSeasonIndex; + + const MediaDetailScreen({super.key, required this.metadata, this.isOffline = false, this.initialSeasonIndex}); @override State createState() => _MediaDetailScreenState(); @@ -77,14 +82,20 @@ class _MediaDetailScreenState extends State bool _isLoadingMetadata = true; List? _extras; late final ScrollController _scrollController; - final ScrollController _seasonsScrollController = ScrollController(); final ScrollController _extrasScrollController = ScrollController(); bool _watchStateChanged = false; double _scrollOffset = 0; - // Locked focus pattern for seasons - int _focusedSeasonIndex = 0; - late final FocusNode _seasonsFocusNode; + // Inline season tabs + int _selectedSeasonIndex = 0; + final Map> _episodeCache = {}; + bool _isLoadingSeasonEpisodes = false; + List _seasonTabFocusNodes = []; + final Map> _seasonContextMenuKeys = {}; + final ScrollController _seasonTabsScrollController = ScrollController(); + final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode'); + final FocusNode _lastEpisodeFocusNode = FocusNode(debugLabel: 'last_episode'); + late final FocusNode _playButtonFocusNode; late final FocusNode _ratingChipFocusNode; Timer? _selectKeyTimer; @@ -95,8 +106,6 @@ class _MediaDetailScreenState extends State // Context menu key for the three-dots button final _contextMenuKey = GlobalKey(); - // GlobalKeys for season cards to access their context menu - final Map> _seasonCardKeys = {}; // Locked focus pattern for extras int _focusedExtraIndex = 0; @@ -125,14 +134,11 @@ class _MediaDetailScreenState extends State @override Set? get watchedRatingKeys { final keys = {widget.metadata.ratingKey}; - if (_showEpisodesDirectly) { - for (final ep in _episodes) { - keys.add(ep.ratingKey); - } - } else { - for (final season in _seasons) { - keys.add(season.ratingKey); - } + for (final season in _seasons) { + keys.add(season.ratingKey); + } + for (final ep in _episodes) { + keys.add(ep.ratingKey); } return keys; } @@ -146,14 +152,11 @@ class _MediaDetailScreenState extends State if (serverId == null) return null; final keys = {toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; - if (_showEpisodesDirectly) { - for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId)); - } - } else { - for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); - } + for (final season in _seasons) { + keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); + } + for (final ep in _episodes) { + keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId)); } return keys; } @@ -161,7 +164,9 @@ class _MediaDetailScreenState extends State @override void onWatchStateChanged(WatchStateEvent event) { if (!widget.isOffline) { - if (_showEpisodesDirectly) { + // If the event matches an episode currently shown, update it directly + final epIndex = _episodes.indexWhere((e) => e.ratingKey == event.ratingKey); + if (epIndex != -1) { _updateEpisodeWatchState(event.ratingKey); } else { _refreshWatchState(); @@ -172,14 +177,11 @@ class _MediaDetailScreenState extends State @override Set? get deletionRatingKeys { final keys = {widget.metadata.ratingKey}; - if (_showEpisodesDirectly) { - for (final ep in _episodes) { - keys.add(ep.ratingKey); - } - } else { - for (final season in _seasons) { - keys.add(season.ratingKey); - } + for (final season in _seasons) { + keys.add(season.ratingKey); + } + for (final ep in _episodes) { + keys.add(ep.ratingKey); } return keys; } @@ -193,14 +195,11 @@ class _MediaDetailScreenState extends State if (serverId == null) return null; final keys = {toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; - if (_showEpisodesDirectly) { - for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId)); - } - } else { - for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); - } + for (final season in _seasons) { + keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); + } + for (final ep in _episodes) { + keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId)); } return keys; } @@ -296,11 +295,17 @@ class _MediaDetailScreenState extends State // Refresh seasons for updated watched counts (also without loader) if (widget.metadata.isShow) { final seasons = await client.getChildren(widget.metadata.ratingKey); + // Clear episode cache so stale watch state data isn't reused + _episodeCache.clear(); setStateIfMounted(() { _seasons = seasons .map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); }); + // Re-fetch episodes for the currently selected season + if (!_showEpisodesDirectly && _seasons.isNotEmpty) { + _fetchSeasonEpisodes(_selectedSeasonIndex); + } } else if (widget.metadata.isSeason) { await _fetchAllEpisodes(); } @@ -318,7 +323,10 @@ class _MediaDetailScreenState extends State if (refreshed != null) { setStateIfMounted(() { final i = _episodes.indexWhere((e) => e.ratingKey == ratingKey); - if (i != -1) _episodes[i] = refreshed; + if (i != -1) { + _episodes[i] = refreshed; + _syncEpisodeToCache(i, refreshed); + } }); } } catch (_) { @@ -331,7 +339,6 @@ class _MediaDetailScreenState extends State super.initState(); _scrollController = ScrollController(); _scrollController.addListener(_onScroll); - _seasonsFocusNode = FocusNode(debugLabel: 'seasons_row'); _extrasFocusNode = FocusNode(debugLabel: 'extras_row'); _playButtonFocusNode = FocusNode(debugLabel: 'play_button'); _ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip'); @@ -349,9 +356,7 @@ class _MediaDetailScreenState extends State @override void dispose() { _scrollController.dispose(); - _seasonsScrollController.dispose(); _extrasScrollController.dispose(); - _seasonsFocusNode.dispose(); _extrasFocusNode.dispose(); _playButtonFocusNode.dispose(); _ratingChipFocusNode.dispose(); @@ -359,6 +364,12 @@ class _MediaDetailScreenState extends State _castFocusNode.dispose(); _castScrollController.dispose(); _selectKeyTimer?.cancel(); + for (final node in _seasonTabFocusNodes) { + node.dispose(); + } + _seasonTabsScrollController.dispose(); + _firstEpisodeFocusNode.dispose(); + _lastEpisodeFocusNode.dispose(); super.dispose(); } @@ -454,6 +465,48 @@ class _MediaDetailScreenState extends State final primaryTrailer = _getPrimaryTrailer(); + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final colorScheme = Theme.of(context).colorScheme; + + // In keyboard/d-pad mode, focused buttons get a prominent style. + // overlayColor is set to transparent to prevent the Material focus + // overlay from dimming the background color we set. + final focusBg = colorScheme.inverseSurface; + final focusFg = colorScheme.onInverseSurface; + final tonalBg = colorScheme.secondaryContainer; + final tonalFg = colorScheme.onSecondaryContainer; + final noOverlay = WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) return Colors.transparent; + return null; // default for other states + }); + + ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding}) { + if (!isKeyboardMode) { + if (padding != null) { + return FilledButton.styleFrom(padding: padding); + } + return IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + foregroundColor: foregroundColor, + ); + } + return ButtonStyle( + padding: padding != null ? WidgetStatePropertyAll(padding) : null, + minimumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, + maximumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, + overlayColor: noOverlay, + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) return focusBg; + return tonalBg; + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) return focusFg; + return foregroundColor ?? tonalFg; + }), + ); + } + return Focus( skipTraversal: true, onKeyEvent: _handlePlayButtonKeyEvent, @@ -463,9 +516,9 @@ class _MediaDetailScreenState extends State height: 48, child: FilledButton( focusNode: _playButtonFocusNode, - autofocus: InputModeTracker.isKeyboardMode(context), + autofocus: isKeyboardMode, onPressed: onPlayPressed, - style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16)), + style: actionButtonStyle(padding: const EdgeInsets.symmetric(horizontal: 16)), child: playButtonLabel.isNotEmpty ? Row( mainAxisSize: MainAxisSize.min, @@ -488,7 +541,7 @@ class _MediaDetailScreenState extends State 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)), + style: actionButtonStyle(), ), const SizedBox(width: 12), ], @@ -501,7 +554,7 @@ class _MediaDetailScreenState extends State 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)), + style: actionButtonStyle(), ), const SizedBox(width: 12), ], @@ -526,7 +579,7 @@ class _MediaDetailScreenState extends State 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)), + style: actionButtonStyle(), ); } @@ -542,7 +595,7 @@ class _MediaDetailScreenState extends State 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)), + style: actionButtonStyle(), ); } @@ -559,7 +612,7 @@ class _MediaDetailScreenState extends State tooltip: tooltip, icon: _buildRadialProgress(progress?.progressPercent), iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + style: actionButtonStyle(), ); } @@ -577,11 +630,7 @@ class _MediaDetailScreenState extends State 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, - ), + style: actionButtonStyle(foregroundColor: Colors.amber), ); } @@ -609,11 +658,7 @@ class _MediaDetailScreenState extends State 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, - ), + style: actionButtonStyle(foregroundColor: Colors.red), ); } @@ -654,11 +699,7 @@ class _MediaDetailScreenState extends State 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, - ), + style: actionButtonStyle(foregroundColor: Colors.grey), ); } @@ -687,11 +728,7 @@ class _MediaDetailScreenState extends State 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, - ), + style: actionButtonStyle(foregroundColor: Colors.orange), ); } @@ -716,11 +753,7 @@ class _MediaDetailScreenState extends State 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, - ), + style: actionButtonStyle(foregroundColor: Colors.green), ); } @@ -746,7 +779,7 @@ class _MediaDetailScreenState extends State 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)), + style: actionButtonStyle(), ); }, ), @@ -798,7 +831,7 @@ class _MediaDetailScreenState extends State 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)), + style: actionButtonStyle(), ), // Three-dots menu button (hidden in offline mode) if (!widget.isOffline) ...[ @@ -818,7 +851,7 @@ class _MediaDetailScreenState extends State }, icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), iconSize: 20, - style: IconButton.styleFrom(minimumSize: const Size(48, 48), maximumSize: const Size(48, 48)), + style: actionButtonStyle(), ), ), ), @@ -908,12 +941,20 @@ class _MediaDetailScreenState extends State Widget _buildUserRatingChip(PlexMetadata metadata) { final hasRating = metadata.userRating != null && metadata.userRating! > 0; final starValue = hasRating ? metadata.userRating! / 2.0 : 0.0; + final colorScheme = Theme.of(context).colorScheme; + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final showFocus = _ratingChipFocusNode.hasFocus && isKeyboardMode; + + final bgColor = showFocus ? colorScheme.inverseSurface : colorScheme.secondaryContainer.withValues(alpha: 0.8); + final fgColor = showFocus ? colorScheme.onInverseSurface : colorScheme.onSecondaryContainer; return FocusableWrapper( focusNode: _ratingChipFocusNode, onSelect: () => _showRatingDialog(metadata, starValue), borderRadius: 100, - useBackgroundFocus: true, + disableScale: true, + focusColor: Colors.transparent, + onFocusChange: (_) => setState(() {}), onKeyEvent: (_, event) { if (!event.isActionable) return KeyEventResult.ignored; final key = event.logicalKey; @@ -928,10 +969,12 @@ class _MediaDetailScreenState extends State }, child: GestureDetector( onTap: () => _showRatingDialog(metadata, starValue), - child: Container( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.8), + color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(100)), ), child: Row( @@ -940,14 +983,14 @@ class _MediaDetailScreenState extends State AppIcon( Symbols.star_rounded, fill: hasRating ? 1 : 0, - color: hasRating ? Colors.amber : Theme.of(context).colorScheme.onSecondaryContainer, + color: showFocus ? fgColor : (hasRating ? Colors.amber : fgColor), size: 16, ), const SizedBox(width: 4), Text( hasRating ? formatRating(starValue) : t.mediaMenu.rate, style: TextStyle( - color: Theme.of(context).colorScheme.onSecondaryContainer, + color: fgColor, fontSize: 13, fontWeight: FontWeight.w500, ), @@ -1164,14 +1207,24 @@ class _MediaDetailScreenState extends State final shouldShowEpisodesDirectly = isAlways || (isSingleSeason && seasonsWithServerId.length == 1); + // Create focus nodes for season tabs + _updateSeasonTabFocusNodes(seasonsWithServerId.length); + + // Auto-select the on-deck season + final onDeckSeasonIndex = _findOnDeckSeasonIndex(seasonsWithServerId); + setStateIfMounted(() { _seasons = seasonsWithServerId; _isLoadingSeasons = false; _showEpisodesDirectly = shouldShowEpisodesDirectly; + _selectedSeasonIndex = onDeckSeasonIndex; }); if (shouldShowEpisodesDirectly) { await _fetchAllEpisodes(); + } else if (seasonsWithServerId.isNotEmpty) { + // Fetch episodes for the auto-selected season + _fetchSeasonEpisodes(onDeckSeasonIndex); } } catch (e) { setStateIfMounted(() { @@ -1217,10 +1270,26 @@ class _MediaDetailScreenState extends State ); }).toList()..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); + // Create focus nodes for season tabs and cache episodes per season + _updateSeasonTabFocusNodes(seasons.length); + for (final entry in seasonMap.entries) { + final seasonRatingKey = entry.value.first.parentRatingKey ?? ''; + _episodeCache[seasonRatingKey] = entry.value..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); + } + + final onDeckSeasonIndex = _findOnDeckSeasonIndex(seasons); + setState(() { _seasons = seasons; _isLoadingSeasons = false; + _selectedSeasonIndex = onDeckSeasonIndex; }); + + // Load episodes for the selected season from cache + if (seasons.isNotEmpty) { + _fetchSeasonEpisodes(onDeckSeasonIndex); + } + if (!(_seasonsCompleter?.isCompleted ?? true)) { _seasonsCompleter?.complete(); } @@ -1239,6 +1308,88 @@ class _MediaDetailScreenState extends State }); } + /// Create or update focus nodes for season tab chips + void _updateSeasonTabFocusNodes(int count) { + if (_seasonTabFocusNodes.length != count) { + for (final node in _seasonTabFocusNodes) { + node.dispose(); + } + _seasonTabFocusNodes = List.generate( + count, + (i) => FocusNode(debugLabel: 'season_tab_$i'), + ); + _seasonContextMenuKeys.clear(); + } + } + + /// Find the season index matching the initial selection or on-deck episode, or fall back to 0 + int _findOnDeckSeasonIndex(List seasons) { + // Prefer explicit initial season (from navigation) + if (widget.initialSeasonIndex != null && seasons.isNotEmpty) { + final idx = seasons.indexWhere((s) => s.index == widget.initialSeasonIndex); + if (idx != -1) return idx; + } + // Fall back to on-deck episode's season + if (_onDeckEpisode != null && seasons.isNotEmpty) { + final onDeckParentIndex = _onDeckEpisode!.parentIndex; + if (onDeckParentIndex != null) { + final idx = seasons.indexWhere((s) => s.index == onDeckParentIndex); + if (idx != -1) return idx; + } + } + return 0; + } + + /// Fetch episodes for a specific season by index, using cache when available + Future _fetchSeasonEpisodes(int seasonIndex) async { + if (seasonIndex < 0 || seasonIndex >= _seasons.length) return; + final season = _seasons[seasonIndex]; + + // Check cache first + final cached = _episodeCache[season.ratingKey]; + if (cached != null) { + setStateIfMounted(() { + _episodes = List.of(cached); + _isLoadingSeasonEpisodes = false; + }); + return; + } + + setStateIfMounted(() => _isLoadingSeasonEpisodes = true); + + try { + if (widget.isOffline) { + // Offline: load from downloads + final downloadProvider = context.read(); + final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.ratingKey); + final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == season.index).toList() + ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); + _episodeCache[season.ratingKey] = seasonEpisodes; + setStateIfMounted(() { + _episodes = List.of(seasonEpisodes); + _isLoadingSeasonEpisodes = false; + }); + } else { + final client = _getClientForMetadata(context); + if (client == null) { + setStateIfMounted(() => _isLoadingSeasonEpisodes = false); + return; + } + final episodes = await client.getChildren(season.ratingKey); + final episodesWithServerId = episodes + .map((e) => e.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) + .toList(); + _episodeCache[season.ratingKey] = episodesWithServerId; + setStateIfMounted(() { + _episodes = List.of(episodesWithServerId); + _isLoadingSeasonEpisodes = false; + }); + } + } catch (e) { + setStateIfMounted(() => _isLoadingSeasonEpisodes = false); + } + } + /// Load extras (trailers, behind-the-scenes, etc.) Future _loadExtras() async { // Only load extras for movies and shows @@ -1272,28 +1423,9 @@ class _MediaDetailScreenState extends State } } - /// Navigate to a season detail screen - Future _navigateToSeason(PlexMetadata season) async { - final watchStateChanged = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MediaDetailScreen(metadata: season, isOffline: widget.isOffline), - ), - ); - if (watchStateChanged == true) { - _watchStateChanged = true; - _updateWatchState(); - } - } - - /// Scroll the main CustomScrollView so the section with the given key is visible + /// Scroll the main scroll view so the section with the given key is centered 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); - } - }); + scrollContextToCenter(key.currentContext); } /// Intercept DOWN from the play button row to focus the first available section @@ -1321,8 +1453,9 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - if ((metadata.isShow || metadata.isSeason) && _seasons.isNotEmpty) { - _seasonsFocusNode.requestFocus(); + if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { + // Focus the selected season tab chip + _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); return KeyEventResult.handled; } @@ -1351,104 +1484,6 @@ class _MediaDetailScreenState extends State return 160.0; } - /// Handle key events for the seasons row (locked focus pattern) - KeyEventResult _handleSeasonsKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - // Let back key propagate to parent Focus handler - if (key.isBackKey) { - return KeyEventResult.ignored; - } - - // Handle SELECT with long-press detection - if (key.isSelectKey) { - if (event is KeyDownEvent) { - // Always reset state on KeyDown to handle cases where KeyUp was - // consumed by a modal (e.g., context menu) and we didn't see it - _selectKeyTimer?.cancel(); - _isSelectKeyDown = true; - _longPressTriggered = false; - _selectKeyTimer = Timer(_longPressDuration, () { - if (!mounted) return; - if (_isSelectKeyDown) { - _longPressTriggered = true; - SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); - // Long-press: show context menu for the focused season - _seasonCardKeys[_focusedSeasonIndex]?.currentState?.showContextMenu(); - } - }); - return KeyEventResult.handled; - } else if (event is KeyRepeatEvent) { - return KeyEventResult.handled; - } else if (event is KeyUpEvent) { - final timerWasActive = _selectKeyTimer?.isActive ?? false; - _selectKeyTimer?.cancel(); - if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) { - // Short tap: navigate to season - if (_focusedSeasonIndex < _seasons.length) { - _navigateToSeason(_seasons[_focusedSeasonIndex]); - } - } - _isSelectKeyDown = false; - _longPressTriggered = false; - return KeyEventResult.handled; - } - } - - if (!event.isActionable) return KeyEventResult.ignored; - if (_seasons.isEmpty) return KeyEventResult.ignored; - - // LEFT: previous season - if (key.isLeftKey) { - if (_focusedSeasonIndex > 0) { - setState(() { - _focusedSeasonIndex--; - }); - scrollListToIndex(_seasonsScrollController, _focusedSeasonIndex, itemExtent: _getResponsiveCardWidth() + 4); - } - return KeyEventResult.handled; - } - - // RIGHT: next season - if (key.isRightKey) { - if (_focusedSeasonIndex < _seasons.length - 1) { - setState(() { - _focusedSeasonIndex++; - }); - scrollListToIndex(_seasonsScrollController, _focusedSeasonIndex, itemExtent: _getResponsiveCardWidth() + 4); - } - return KeyEventResult.handled; - } - - // UP: overview → play button - if (key.isUpKey) { - final metadata = _fullMetadata ?? widget.metadata; - if (metadata.summary != null && metadata.summary!.isNotEmpty) { - _overviewFocusNode.requestFocus(); - _scrollSectionIntoView(_overviewSectionKey); - } else { - _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); - _playButtonFocusNode.requestFocus(); - } - return KeyEventResult.handled; - } - - // DOWN: cast → extras - if (key.isDownKey) { - final metadata = _fullMetadata ?? widget.metadata; - if (metadata.role != null && metadata.role!.isNotEmpty) { - _castFocusNode.requestFocus(); - _scrollSectionIntoView(_castSectionKey); - } else if (_extras != null && _extras!.isNotEmpty) { - _extrasFocusNode.requestFocus(); - _scrollSectionIntoView(_extrasSectionKey); - } - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - /// Handle key events for the overview section KeyEventResult _handleOverviewKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; @@ -1464,10 +1499,10 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - // DOWN: seasons/episodes (if show/season) → cast → extras + // DOWN: season tabs → cast → extras if (key.isDownKey) { - if ((metadata.isShow || metadata.isSeason) && _seasons.isNotEmpty) { - _seasonsFocusNode.requestFocus(); + if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { + _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); } else if (metadata.role != null && metadata.role!.isNotEmpty) { _castFocusNode.requestFocus(); @@ -1487,62 +1522,101 @@ class _MediaDetailScreenState extends State return KeyEventResult.ignored; } - /// Build horizontal seasons list for larger screens (>=600px) - /// Uses locked focus pattern for D-pad centered scrolling - Widget _buildHorizontalSeasons() { - final cardWidth = _getResponsiveCardWidth(); - final posterHeight = (cardWidth - 16) * 1.5; - final containerHeight = posterHeight + 66; + /// Show context menu for a season tab + void _showSeasonTabContextMenu(int index, {Offset? position}) { + final key = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey()); + key.currentState?.showContextMenu(context, position: position); + } - final hasFocus = _seasonsFocusNode.hasFocus; + /// Focus the currently selected season tab + void _focusSelectedSeasonTab() { + if (_seasonTabFocusNodes.length > _selectedSeasonIndex) { + _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); + } + } - return Focus( - focusNode: _seasonsFocusNode, - onKeyEvent: _handleSeasonsKeyEvent, - child: SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - controller: _seasonsScrollController, - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 12), - itemCount: _seasons.length, - itemBuilder: (context, index) { - final season = _seasons[index]; - final isFocused = hasFocus && index == _focusedSeasonIndex; - // Get or create a GlobalKey for this season card - final cardKey = _seasonCardKeys.putIfAbsent(index, () => GlobalKey()); + /// Scroll a season tab into view within the horizontal scroll + void _scrollSeasonTabIntoView(int index) { + if (index < 0 || index >= _seasonTabFocusNodes.length) return; + scrollContextToCenter(_seasonTabFocusNodes[index].context); + } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isFocused, - onTap: () => _navigateToSeason(season), - child: MediaCard( - key: cardKey, - item: season, - width: cardWidth, - height: posterHeight, - forceGridMode: true, - isOffline: widget.isOffline, - onRefresh: (_) { - _watchStateChanged = true; - _updateWatchState(); + /// Build inline season tab chips with LEFT/RIGHT/DOWN focus navigation + Widget _buildSeasonTabs() { + return HorizontalScrollWithArrows( + controller: _seasonTabsScrollController, + builder: (scrollController) => SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: List.generate(_seasons.length, (index) { + final season = _seasons[index]; + final contextMenuKey = _seasonContextMenuKeys.putIfAbsent( + index, + () => GlobalKey(), + ); + Offset? tapPosition; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: MediaContextMenu( + key: contextMenuKey, + item: season, + onRefresh: (_) { + _watchStateChanged = true; + _updateWatchState(); + }, + onListRefresh: () { + if (widget.isOffline) { + _loadSeasonsFromDownloads(); + } else { + _loadSeasons(); + } + }, + child: GestureDetector( + onTapDown: (details) => tapPosition = details.globalPosition, + onLongPress: () => _showSeasonTabContextMenu(index, position: tapPosition), + onSecondaryTapDown: (details) => tapPosition = details.globalPosition, + onSecondaryTap: () => _showSeasonTabContextMenu(index, position: tapPosition), + child: FocusableTabChip( + label: season.title, + isSelected: index == _selectedSeasonIndex, + focusNode: _seasonTabFocusNodes.length > index ? _seasonTabFocusNodes[index] : null, + onSelect: () { + if (index == _selectedSeasonIndex) return; + setState(() => _selectedSeasonIndex = index); + _fetchSeasonEpisodes(index); }, - onListRefresh: () { - if (widget.isOffline) { - _loadSeasonsFromDownloads(); - } else { - _loadSeasons(); - } + onNavigateLeft: index > 0 + ? () { + final newIndex = index - 1; + setState(() => _selectedSeasonIndex = newIndex); + _seasonTabFocusNodes[newIndex].requestFocus(); + _scrollSeasonTabIntoView(newIndex); + _fetchSeasonEpisodes(newIndex); + } + : null, + onNavigateRight: index < _seasons.length - 1 + ? () { + final newIndex = index + 1; + setState(() => _selectedSeasonIndex = newIndex); + _seasonTabFocusNodes[newIndex].requestFocus(); + _scrollSeasonTabIntoView(newIndex); + _fetchSeasonEpisodes(newIndex); + } + : null, + onNavigateDown: () { + _firstEpisodeFocusNode.requestFocus(); + }, + onLongPress: () => _showSeasonTabContextMenu(index), + onBack: () { + Navigator.of(context).maybePop(); }, ), ), - ); - }, - ), + ), + ); + }), ), ), ); @@ -1606,14 +1680,14 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - // UP: cast → seasons/episodes (if show/season) → overview → play button + // UP: cast → season tabs → overview → play button if (key.isUpKey) { final metadata = _fullMetadata ?? widget.metadata; if (metadata.role != null && metadata.role!.isNotEmpty) { _castFocusNode.requestFocus(); _scrollSectionIntoView(_castSectionKey); - } else if ((metadata.isShow || metadata.isSeason) && _seasons.isNotEmpty) { - _seasonsFocusNode.requestFocus(); + } else if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { + _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); } else if (metadata.summary != null && metadata.summary!.isNotEmpty) { _overviewFocusNode.requestFocus(); @@ -1660,10 +1734,17 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - // UP: seasons/episodes (if show/season) → overview → play button + // UP: season tabs → overview → play button if (key.isUpKey) { - if ((metadata.isShow || metadata.isSeason) && _seasons.isNotEmpty) { - _seasonsFocusNode.requestFocus(); + // If episodes are visible, focus the last episode (cast is right below episodes) + if (_episodes.isNotEmpty) { + // For single episode, _lastEpisodeFocusNode isn't attached — use first + final target = _episodes.length == 1 ? _firstEpisodeFocusNode : _lastEpisodeFocusNode; + target.requestFocus(); + return KeyEventResult.handled; + } + if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { + _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); } else if (metadata.summary != null && metadata.summary!.isNotEmpty) { _overviewFocusNode.requestFocus(); @@ -1712,6 +1793,12 @@ class _MediaDetailScreenState extends State client: client, isOffline: widget.isOffline, autofocus: false, + focusNode: index == 0 + ? _firstEpisodeFocusNode + : index == _episodes.length - 1 && _episodes.length > 1 + ? _lastEpisodeFocusNode + : null, + onNavigateUp: index == 0 ? _focusSelectedSeasonTab : null, localPosterPath: localPosterPath, onTap: () async { await navigateToVideoPlayerWithRefresh( @@ -1723,6 +1810,7 @@ class _MediaDetailScreenState extends State if (refreshed != null) { setStateIfMounted(() { _episodes[index] = refreshed; + _syncEpisodeToCache(index, refreshed); }); } }, @@ -1735,16 +1823,42 @@ class _MediaDetailScreenState extends State if (refreshed != null) { setStateIfMounted(() { final i = _episodes.indexWhere((e) => e.ratingKey == ratingKey); - if (i != -1) _episodes[i] = refreshed; + if (i != -1) { + _episodes[i] = refreshed; + _syncEpisodeToCache(i, refreshed); + } }); } }, - onListRefresh: widget.isOffline ? null : _fetchAllEpisodes, + onListRefresh: widget.isOffline ? null : _refreshCurrentEpisodes, ); }, ); } + /// Sync an updated episode back into the episode cache + void _syncEpisodeToCache(int episodeIndex, PlexMetadata updated) { + if (_showEpisodesDirectly || _seasons.isEmpty) return; + if (_selectedSeasonIndex >= _seasons.length) return; + final season = _seasons[_selectedSeasonIndex]; + final cached = _episodeCache[season.ratingKey]; + if (cached != null && episodeIndex < cached.length) { + cached[episodeIndex] = updated; + } + } + + /// Refresh episodes for the current context (inline season or all flattened) + Future _refreshCurrentEpisodes() async { + if (_showEpisodesDirectly) { + await _fetchAllEpisodes(); + } else if (_seasons.isNotEmpty) { + // Clear cache for current season and re-fetch + final season = _seasons[_selectedSeasonIndex]; + _episodeCache.remove(season.ratingKey); + await _fetchSeasonEpisodes(_selectedSeasonIndex); + } + } + Future _fetchAllEpisodes() async { if (_seasons.isEmpty) return; final client = _getClientForMetadata(context); @@ -1763,43 +1877,6 @@ class _MediaDetailScreenState extends State } } - /// Build vertical seasons list for smaller screens (<600px) - Widget _buildVerticalSeasons() { - return ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - itemCount: _seasons.length, - separatorBuilder: (context, index) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final season = _seasons[index]; - // Look up each season's artwork, not the show's - String? seasonPosterPath; - if (widget.isOffline && season.serverId != null) { - seasonPosterPath = context.read().getArtworkLocalPath(season.serverId!, season.thumb); - } - return _SeasonCard( - season: season, - client: _getClientForMetadata(context), - isOffline: widget.isOffline, - localPosterPath: seasonPosterPath, - onTap: () => _navigateToSeason(season), - onRefresh: () { - _watchStateChanged = true; - _updateWatchState(); - }, - onListRefresh: () { - if (widget.isOffline) { - _loadSeasonsFromDownloads(); - } else { - _loadSeasons(); - } - }, - ); - }, - ); - } - /// Load the next unwatched episode for offline mode (offline OnDeck) Future _loadOfflineOnDeckEpisode() async { final offlineWatchProvider = context.read(); @@ -2318,29 +2395,12 @@ class _MediaDetailScreenState extends State ], // Seasons / Episodes (for TV shows and seasons) - if (isShow || metadata.isSeason) ...[ - Text( - key: _seasonsSectionKey, - _showEpisodesDirectly ? t.libraries.groupings.episodes : t.discover.seasons, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - if (_isLoadingSeasons || _isLoadingEpisodes) + if (isShow && !_showEpisodesDirectly) ...[ + // Season tabs + inline episodes + if (_isLoadingSeasons) const Center( child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), ) - else if (_showEpisodesDirectly && _episodes.isNotEmpty) - _buildEpisodesList() - else if (_showEpisodesDirectly && _episodes.isEmpty) - Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Text( - t.messages.noEpisodesFoundGeneral, - style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), - ), - ), - ) else if (_seasons.isEmpty) Padding( padding: const EdgeInsets.all(32), @@ -2351,10 +2411,57 @@ class _MediaDetailScreenState extends State ), ), ) - else if (size.width >= 600) - _buildHorizontalSeasons() + else ...[ + Text( + key: _seasonsSectionKey, + t.libraries.groupings.episodes, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + _buildSeasonTabs(), + const SizedBox(height: 16), + if (_isLoadingSeasonEpisodes) + const Center( + child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), + ) + else if (_episodes.isNotEmpty) + _buildEpisodesList() + else + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + t.messages.noEpisodesFoundGeneral, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), + ), + ), + ), + ], + const SizedBox(height: 24), + ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ + // Server says flatten — existing behavior unchanged + Text( + key: _seasonsSectionKey, + t.libraries.groupings.episodes, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + if (_isLoadingSeasons || _isLoadingEpisodes) + const Center( + child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), + ) + else if (_episodes.isNotEmpty) + _buildEpisodesList() else - _buildVerticalSeasons(), + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + t.messages.noEpisodesFoundGeneral, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), + ), + ), + ), const SizedBox(height: 24), ], @@ -2677,194 +2784,3 @@ class _MediaDetailScreenState extends State } } -/// Season card widget with D-pad long-press support -class _SeasonCard extends StatefulWidget { - final PlexMetadata season; - final PlexClient? client; - final VoidCallback onTap; - final VoidCallback onRefresh; - final VoidCallback? onListRefresh; - final bool isOffline; - final String? localPosterPath; - - const _SeasonCard({ - required this.season, - this.client, - required this.onTap, - required this.onRefresh, - this.onListRefresh, - this.isOffline = false, - this.localPosterPath, - }); - - @override - State<_SeasonCard> createState() => _SeasonCardState(); -} - -class _SeasonCardState extends State<_SeasonCard> { - final _contextMenuKey = GlobalKey(); - Offset? _tapPosition; - - void _storeTapPosition(TapDownDetails details) { - _tapPosition = details.globalPosition; - } - - void _showContextMenu() { - _contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition); - } - - @override - Widget build(BuildContext context) { - return FocusableWrapper( - enableLongPress: true, - onSelect: widget.onTap, - onLongPress: _showContextMenu, - borderRadius: 12, // Match card border radius - child: Card( - clipBehavior: Clip.antiAlias, - child: MediaContextMenu( - key: _contextMenuKey, - item: widget.season, - onRefresh: (ratingKey) => widget.onRefresh(), - onListRefresh: widget.onListRefresh, - onTap: widget.onTap, - child: Semantics( - label: "media-season-${widget.season.ratingKey}", - identifier: "media-season-${widget.season.ratingKey}", - button: true, - hint: "Tap to view ${widget.season.title}", - child: InkWell( - onTap: widget.onTap, - onTapDown: _storeTapPosition, - onLongPress: _showContextMenu, - onSecondaryTapDown: _storeTapPosition, - onSecondaryTap: _showContextMenu, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Season poster - ClipRRect(borderRadius: const BorderRadius.all(Radius.circular(6)), child: _buildSeasonPoster()), - const SizedBox(width: 16), - - // Season info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.season.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - if (widget.season.leafCount != null) - Text( - t.discover.episodeCount(count: widget.season.leafCount.toString()), - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), - ), - if (widget.season.userRating != null && widget.season.userRating! > 0) ...[ - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Padding( - padding: EdgeInsets.only(top: 2), - child: Icon(Symbols.star_rounded, size: 14, fill: 1, color: Colors.amber), - ), - const SizedBox(width: 3), - Text( - (widget.season.userRating! / 2) == (widget.season.userRating! / 2).truncateToDouble() - ? '${(widget.season.userRating! / 2).toInt()}' - : (widget.season.userRating! / 2).toStringAsFixed(1), - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), - ), - ], - ), - ], - // Hide watch progress when offline (not tracked) - if (!widget.isOffline) ...[ - const SizedBox(height: 8), - if (widget.season.viewedLeafCount != null && widget.season.leafCount != null) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 200, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: LinearProgressIndicator( - value: widget.season.viewedLeafCount! / widget.season.leafCount!, - backgroundColor: tokens(context).outline, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - minHeight: 6, - ), - ), - ), - const SizedBox(height: 4), - Text( - t.discover.watchedProgress( - watched: widget.season.viewedLeafCount.toString(), - total: widget.season.leafCount.toString(), - ), - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), - ), - ], - ), - ], - ], - ), - ), - - const AppIcon(Symbols.chevron_right_rounded, fill: 1), - ], - ), - ), - ), - ), - ), - ), - ); - } - - Widget _buildSeasonPoster() { - if (widget.isOffline && widget.localPosterPath != null) { - return Image.file( - File(widget.localPosterPath!), - width: 80, - height: 120, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 80, - height: 120, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32), - ), - ); - } - if (widget.season.thumb != null) { - return PlexOptimizedImage.poster( - client: widget.client, - imagePath: widget.season.thumb, - width: 80, - height: 120, - fit: BoxFit.cover, - placeholder: (context, url) => - Container(width: 80, height: 120, color: Theme.of(context).colorScheme.surfaceContainerHighest), - errorWidget: (context, url, error) => Container( - width: 80, - height: 120, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32), - ), - ); - } - return Container( - width: 80, - height: 120, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32), - ); - } -} diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index 0df7791a..4451c3ee 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -95,6 +95,33 @@ Future navigateToMediaItem( continue defaultCase; case PlexMediaType.season: + // Navigate to the parent show with the season tab pre-selected + if (metadata.parentRatingKey != null) { + final showStub = PlexMetadata( + ratingKey: metadata.parentRatingKey!, + key: '/library/metadata/${metadata.parentRatingKey}', + type: 'show', + title: metadata.grandparentTitle ?? metadata.parentTitle ?? metadata.displayTitle, + thumb: metadata.grandparentThumb ?? metadata.parentThumb, + art: metadata.grandparentArt, + serverId: metadata.serverId, + serverName: metadata.serverName, + ); + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen( + metadata: showStub, + isOffline: isOffline, + initialSeasonIndex: metadata.index, + ), + ), + ); + if (result == true) { + onRefresh?.call(metadata.ratingKey); + } + return MediaNavigationResult.navigated; + } continue defaultCase; defaultCase: diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index da7f418d..6df035e2 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -1,5 +1,17 @@ import 'package:flutter/widgets.dart'; +/// Scroll the nearest scrollable ancestor so [context] is centered. +/// +/// Uses [Scrollable.ensureVisible] with alignment 0.5 (center). +/// Runs in a post-frame callback to ensure layout is complete. +void scrollContextToCenter(BuildContext? context) { + if (context == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + Scrollable.ensureVisible(context, alignment: 0.5, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + }); +} + /// Scroll a horizontal list to center the item at the given index. /// /// Assumes items are laid out with [leadingPadding] before the first item, diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index de5d01f1..e146dea6 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../focus/focus_theme.dart'; import '../focus/focusable_wrapper.dart'; import '../models/download_models.dart'; import '../providers/download_provider.dart'; @@ -30,6 +31,8 @@ class EpisodeCard extends StatefulWidget { final bool autofocus; final bool isOffline; final String? localPosterPath; + final FocusNode? focusNode; + final VoidCallback? onNavigateUp; const EpisodeCard({ super.key, @@ -41,6 +44,8 @@ class EpisodeCard extends StatefulWidget { this.autofocus = false, this.isOffline = false, this.localPosterPath, + this.focusNode, + this.onNavigateUp, }); @override @@ -106,33 +111,37 @@ class _EpisodeCardState extends State { final hasActiveProgress = hasProgress && widget.episode.viewOffset! < widget.episode.duration!; - return FocusableWrapper( - autofocus: widget.autofocus, - enableLongPress: true, - onSelect: widget.onTap, - onLongPress: _showContextMenu, - borderRadius: 0, // Episode cards have no border radius - useBackgroundFocus: true, // Use background color instead of outline - disableScale: true, // No scale animation for list items - child: MediaContextMenu( - key: _contextMenuKey, - item: widget.episode, - onRefresh: widget.onRefresh, - onListRefresh: widget.onListRefresh, - onTap: widget.onTap, - child: InkWell( - key: Key(widget.episode.ratingKey), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: FocusableWrapper( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + enableLongPress: true, + onNavigateUp: widget.onNavigateUp, + onSelect: widget.onTap, + onLongPress: _showContextMenu, + disableScale: true, + child: MediaContextMenu( + key: _contextMenuKey, + item: widget.episode, + onRefresh: widget.onRefresh, + onListRefresh: widget.onListRefresh, onTap: widget.onTap, - onTapDown: _storeTapPosition, - onLongPress: _showContextMenu, - onSecondaryTapDown: _storeTapPosition, - onSecondaryTap: _showContextMenu, - hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05), - child: Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: tokens(context).outline, width: 0.5)), - ), - padding: const EdgeInsets.all(16), + child: InkWell( + key: Key(widget.episode.ratingKey), + borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius), + onTap: widget.onTap, + onTapDown: _storeTapPosition, + onLongPress: _showContextMenu, + onSecondaryTapDown: _storeTapPosition, + onSecondaryTap: _showContextMenu, + hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius), + ), + padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -393,6 +402,7 @@ class _EpisodeCardState extends State { ), ), ), + ), ); } diff --git a/lib/widgets/focusable_tab_chip.dart b/lib/widgets/focusable_tab_chip.dart index 0b20c06b..6639babc 100644 --- a/lib/widgets/focusable_tab_chip.dart +++ b/lib/widgets/focusable_tab_chip.dart @@ -33,6 +33,9 @@ class FocusableTabChip extends StatefulWidget { /// Called when the user presses BACK from this chip. final VoidCallback? onBack; + /// Called when SELECT key is held (D-pad long press). + final VoidCallback? onLongPress; + const FocusableTabChip({ super.key, required this.label, @@ -43,6 +46,7 @@ class FocusableTabChip extends StatefulWidget { this.onNavigateRight, this.onNavigateDown, this.onBack, + this.onLongPress, }); @override @@ -80,6 +84,7 @@ class _FocusableTabChipState extends State with FocusableChipS event, ChipKeyCallbacks( onSelect: widget.onSelect, + onLongPress: widget.onLongPress, onNavigateLeft: widget.onNavigateLeft, onNavigateRight: widget.onNavigateRight, onNavigateDown: widget.onNavigateDown, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 2ddf7418..24cb89e2 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -934,28 +934,53 @@ bool _hasClickableTitle(PlexMetadata metadata) { return false; } -/// Navigate to season detail from episode metadata +/// Navigate to a show with the season tab pre-selected from episode metadata void _navigateToSeason(BuildContext context, PlexMetadata episode, {bool isOffline = false}) { - if (episode.parentRatingKey == null) return; - final seasonStub = PlexMetadata( - ratingKey: episode.parentRatingKey!, - key: '/library/metadata/${episode.parentRatingKey}', - type: 'season', - title: episode.parentTitle ?? 'Season ${episode.parentIndex ?? ''}', - index: episode.parentIndex, - parentRatingKey: episode.grandparentRatingKey, - thumb: episode.parentThumb, - serverId: episode.serverId, - serverName: episode.serverName, - ); - Navigator.push(context, MaterialPageRoute(builder: (_) => MediaDetailScreen(metadata: seasonStub, isOffline: isOffline))); + if (episode.grandparentRatingKey != null) { + // Navigate to the show with the season pre-selected + final showStub = PlexMetadata( + ratingKey: episode.grandparentRatingKey!, + key: '/library/metadata/${episode.grandparentRatingKey}', + type: 'show', + title: episode.grandparentTitle ?? episode.displayTitle, + thumb: episode.grandparentThumb, + art: episode.grandparentArt, + serverId: episode.serverId, + serverName: episode.serverName, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MediaDetailScreen( + metadata: showStub, + isOffline: isOffline, + initialSeasonIndex: episode.parentIndex, + ), + ), + ); + } else if (episode.parentRatingKey != null) { + // Fallback: navigate to season directly if no grandparent + final seasonStub = PlexMetadata( + ratingKey: episode.parentRatingKey!, + key: '/library/metadata/${episode.parentRatingKey}', + type: 'season', + title: episode.parentTitle ?? 'Season ${episode.parentIndex ?? ''}', + index: episode.parentIndex, + parentRatingKey: episode.grandparentRatingKey, + thumb: episode.parentThumb, + serverId: episode.serverId, + serverName: episode.serverName, + ); + Navigator.push(context, MaterialPageRoute(builder: (_) => MediaDetailScreen(metadata: seasonStub, isOffline: isOffline))); + } } /// Navigate to the detail screen for a metadata item. -/// For episodes/seasons: navigates to the parent show. +/// For episodes/seasons: navigates to the parent show with season pre-selected. /// For movies and other types: navigates to the item's own detail page. void _navigateToDetail(BuildContext context, PlexMetadata metadata, {bool isOffline = false}) { PlexMetadata target = metadata; + int? initialSeasonIndex; if (metadata.isEpisode && metadata.grandparentRatingKey != null) { target = PlexMetadata( @@ -969,6 +994,7 @@ void _navigateToDetail(BuildContext context, PlexMetadata metadata, {bool isOffl serverName: metadata.serverName, ); } else if (metadata.isSeason && metadata.parentRatingKey != null) { + initialSeasonIndex = metadata.index; target = PlexMetadata( ratingKey: metadata.parentRatingKey!, key: '/library/metadata/${metadata.parentRatingKey}', @@ -981,7 +1007,12 @@ void _navigateToDetail(BuildContext context, PlexMetadata metadata, {bool isOffl ); } - Navigator.push(context, MaterialPageRoute(builder: (_) => MediaDetailScreen(metadata: target, isOffline: isOffline))); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MediaDetailScreen(metadata: target, isOffline: isOffline, initialSeasonIndex: initialSeasonIndex), + ), + ); } /// Text widget that shows hover underline + pointer cursor only in pointer mode. diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index c639fe7c..ca51beb5 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -462,10 +462,15 @@ class MediaContextMenuState extends State { case 'season': didNavigate = true; + // Navigate to the show with the season tab pre-selected + final seasonParentKey = metadata!.mediaType == PlexMediaType.episode + ? metadata.grandparentRatingKey + : metadata.parentRatingKey; + final seasonIndex = metadata.parentIndex; await _navigateToRelated( context, - metadata!.parentRatingKey, - (metadata) => MediaDetailScreen(metadata: metadata), + seasonParentKey, + (show) => MediaDetailScreen(metadata: show, initialSeasonIndex: seasonIndex), t.messages.errorLoadingSeason, ); break;