diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index 4abc5b82..66b1916b 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -77,3 +77,26 @@ class SelectKeyUpSuppressor { return false; } } + +/// Global helper to suppress the next BACK key-up event. +/// +/// Use this when a modal (bottom sheet, dialog) closes to prevent +/// the BACK key-up from propagating to the underlying screen. +class BackKeyUpSuppressor { + static bool _suppressBackUntilKeyUp = false; + + static void suppressBackUntilKeyUp() { + _suppressBackUntilKeyUp = true; + } + + static bool consumeIfSuppressed(KeyEvent event) { + if (!_suppressBackUntilKeyUp) return false; + if (event.logicalKey.isBackKey) { + if (event is KeyUpEvent) { + _suppressBackUntilKeyUp = false; + } + return true; + } + return false; + } +} diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 773a8a8c..488d3b9d 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -36,6 +36,9 @@ class FocusableWrapper extends StatefulWidget { /// Called when the user presses LEFT and there's no focusable item to the left. final VoidCallback? onNavigateLeft; + /// Called when the user presses RIGHT and there's no focusable item to the right. + final VoidCallback? onNavigateRight; + /// Called when the user presses BACK. final VoidCallback? onBack; @@ -92,6 +95,7 @@ class FocusableWrapper extends StatefulWidget { this.onFocusChange, this.onNavigateUp, this.onNavigateLeft, + this.onNavigateRight, this.onBack, this.autofocus = false, this.focusNode, @@ -377,6 +381,13 @@ class _FocusableWrapperState extends State with SingleTickerPr return KeyEventResult.handled; } + // RIGHT arrow - if callback provided, navigate right (caller is responsible + // for only providing this callback when the item is at the right edge) + if (key == LogicalKeyboardKey.arrowRight && widget.onNavigateRight != null) { + widget.onNavigateRight!(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart index 17d63c27..21ba8a56 100644 --- a/lib/focus/key_event_utils.dart +++ b/lib/focus/key_event_utils.dart @@ -56,12 +56,14 @@ class BackKeyCoordinator { /// /// This consumes KeyDown/KeyRepeat to avoid duplicate actions from key repeat. /// Optionally suppresses stray KeyUp events delivered to the next route after a pop. -KeyEventResult handleBackKeyAction( - KeyEvent event, - VoidCallback onBack, -) { +KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) { if (!event.logicalKey.isBackKey) return KeyEventResult.ignored; + // Check if this BACK event should be suppressed (e.g., after modal closed) + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { BackKeyCoordinator.markHandled(); onBack(); diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 9ac1dd6f..ca8ff34f 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -34,7 +34,7 @@ class DownloadsScreenState extends State with SingleTickerProvi final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies'); /// When true, suppress auto-focus in tabs (used when navigating via tab bar) - bool _suppressAutoFocus = false; + bool _suppressAutoFocus = true; @override void initState() { @@ -259,6 +259,9 @@ class DownloadsScreenState extends State with SingleTickerProvi }, onCancel: downloadProvider.cancelDownload, onDelete: downloadProvider.deleteDownload, + onNavigateLeft: () => MainScreenFocusScope.of(context)?.focusSidebar(), + onBack: focusTabBar, + suppressAutoFocus: _suppressAutoFocus, ); }, ), @@ -287,15 +290,41 @@ class DownloadsScreenState extends State with SingleTickerProvi enum DownloadType { manage, tvShows, movies } /// Grid content for TV Shows and Movies tabs -class _DownloadsGridContent extends StatelessWidget { +class _DownloadsGridContent extends StatefulWidget { final DownloadType type; final bool suppressAutoFocus; final VoidCallback? onBack; const _DownloadsGridContent({required this.type, required this.suppressAutoFocus, this.onBack}); + @override + State<_DownloadsGridContent> createState() => _DownloadsGridContentState(); +} + +class _DownloadsGridContentState extends State<_DownloadsGridContent> { + final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'DownloadsGrid_firstItem'); + + @override + void dispose() { + _firstItemFocusNode.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(_DownloadsGridContent oldWidget) { + super.didUpdateWidget(oldWidget); + // When suppressAutoFocus changes from true to false, focus the first item + if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _firstItemFocusNode.canRequestFocus) { + _firstItemFocusNode.requestFocus(); + } + }); + } + } + /// Navigate focus to the sidebar - void _navigateToSidebar(BuildContext context) { + void _navigateToSidebar() { MainScreenFocusScope.of(context)?.focusSidebar(); } @@ -308,36 +337,41 @@ class _DownloadsGridContent extends StatelessWidget { Widget build(BuildContext context) { return Consumer2( builder: (context, downloadProvider, settingsProvider, _) { - final List items = type == DownloadType.tvShows + final List items = widget.type == DownloadType.tvShows ? downloadProvider.downloadedShows : downloadProvider.downloadedMovies; if (items.isEmpty) { - return _buildEmptyState(context); + return _buildEmptyState(); } - const padding = EdgeInsets.symmetric(horizontal: 8); + // Extra top padding for focus decoration (scale + border extends beyond item bounds) + const effectivePadding = EdgeInsets.only(left: 8, right: 8, top: 8); final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); const crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; // Use LayoutBuilder to get actual available width (accounting for sidebar) return LayoutBuilder( builder: (context, constraints) { - final availableWidth = constraints.maxWidth - padding.left - padding.right; + final availableWidth = constraints.maxWidth - effectivePadding.left - effectivePadding.right; final columnCount = _calculateColumnCount(availableWidth, maxCrossAxisExtent, crossAxisSpacing); return GridView.builder( - padding: padding, + padding: effectivePadding, + // Allow focus decoration to render outside scroll bounds + clipBehavior: Clip.none, gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity), itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); + final isFirst = index == 0; return FocusableMediaCard( item: item, - onBack: onBack, + focusNode: isFirst ? _firstItemFocusNode : null, + onBack: widget.onBack, isOffline: true, // Downloaded content works without server - onNavigateLeft: isFirstColumn ? () => _navigateToSidebar(context) : null, + onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, ); }, ); @@ -347,7 +381,7 @@ class _DownloadsGridContent extends StatelessWidget { ); } - Widget _buildEmptyState(BuildContext context) { + Widget _buildEmptyState() { return EmptyStateWidget( message: t.downloads.noDownloads, subtitle: t.downloads.noDownloadsDescription, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index e0584b91..930c4928 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -9,6 +9,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; +import '../focus/focusable_wrapper.dart'; import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../widgets/focus_builders.dart'; @@ -68,6 +69,9 @@ class _MediaDetailScreenState extends State with WatchStateAw bool _longPressTriggered = false; static const _longPressDuration = Duration(milliseconds: 500); + // GlobalKeys for season cards to access their context menu + final Map> _seasonCardKeys = {}; + // WatchStateAware: watch the show/movie and all season ratingKeys @override Set? get watchedRatingKeys { @@ -809,19 +813,20 @@ class _MediaDetailScreenState extends State with WatchStateAw // Handle SELECT with long-press detection if (key.isSelectKey) { if (event is KeyDownEvent) { - if (!_isSelectKeyDown) { - _isSelectKeyDown = true; - _longPressTriggered = false; - _selectKeyTimer?.cancel(); - _selectKeyTimer = Timer(_longPressDuration, () { - if (!mounted) return; - if (_isSelectKeyDown) { - _longPressTriggered = true; - SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); - // Long-press: could show context menu if needed - } - }); - } + // 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; @@ -909,6 +914,8 @@ class _MediaDetailScreenState extends State with WatchStateAw 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()); return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), @@ -917,6 +924,7 @@ class _MediaDetailScreenState extends State with WatchStateAw isFocused: isFocused, onTap: () => _navigateToSeason(season), child: MediaCard( + key: cardKey, item: season, width: cardWidth, height: posterHeight, @@ -1698,14 +1706,16 @@ class _MediaDetailScreenState extends State with WatchStateAw } } -/// Season card widget -class _SeasonCard extends StatelessWidget { +/// 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 bool isOffline; final String? localPosterPath; + final FocusNode? focusNode; + final bool autofocus; const _SeasonCard({ required this.season, @@ -1714,122 +1724,146 @@ class _SeasonCard extends StatelessWidget { required this.onRefresh, this.isOffline = false, this.localPosterPath, + this.focusNode, + this.autofocus = false, }); @override - Widget build(BuildContext context) { - return Card( - clipBehavior: Clip.antiAlias, - child: MediaContextMenu( - item: season, - onRefresh: (ratingKey) => onRefresh(), - onTap: onTap, - child: Semantics( - label: "media-season-${season.ratingKey}", - identifier: "media-season-${season.ratingKey}", - button: true, - hint: "Tap to view ${season.title}", - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Season poster - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: isOffline && localPosterPath != null - ? Image.file( - File(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), - ), - ) - : season.thumb != null - ? PlexOptimizedImage.poster( - client: client, - imagePath: 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), - ), - ) - : Container( - width: 80, - height: 120, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32), - ), - ), - const SizedBox(width: 16), + State<_SeasonCard> createState() => _SeasonCardState(); +} - // Season info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - season.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - if (season.leafCount != null) +class _SeasonCardState extends State<_SeasonCard> { + final _contextMenuKey = GlobalKey(); + + void _showContextMenu() { + _contextMenuKey.currentState?.showContextMenu(context); + } + + @override + Widget build(BuildContext context) { + return FocusableWrapper( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + 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(), + 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, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Season poster + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: widget.isOffline && widget.localPosterPath != null + ? 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), + ), + ) + : widget.season.thumb != null + ? 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), + ), + ) + : Container( + width: 80, + height: 120, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: const AppIcon(Symbols.movie_rounded, fill: 1, size: 32), + ), + ), + const SizedBox(width: 16), + + // Season info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - t.discover.episodeCount(count: season.leafCount.toString()), - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + widget.season.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), - // Hide watch progress when offline (not tracked) - if (!isOffline) ...[ - const SizedBox(height: 8), - if (season.viewedLeafCount != null && season.leafCount != null) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 200, - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: season.viewedLeafCount! / season.leafCount!, - backgroundColor: tokens(context).outline, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), - minHeight: 6, + 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), + ), + // 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: BorderRadius.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: season.viewedLeafCount.toString(), - total: season.leafCount.toString(), + 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), ), - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), - ), - ], - ), + ], + ), + ], ], - ], + ), ), - ), - const AppIcon(Symbols.chevron_right_rounded, fill: 1), - ], + const AppIcon(Symbols.chevron_right_rounded, fill: 1), + ], + ), ), ), ), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 6d961af1..9cd26ea2 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../services/plex_client.dart'; import '../main.dart'; +import '../focus/focusable_wrapper.dart'; import '../focus/key_event_utils.dart'; import '../focus/dpad_navigator.dart'; import '../focus/input_mode_tracker.dart'; @@ -254,8 +255,8 @@ class _SeasonDetailScreenState extends State with ItemUpdata } } -/// Episode card widget -class _EpisodeCard extends StatelessWidget { +/// Episode card widget with D-pad long-press support +class _EpisodeCard extends StatefulWidget { final PlexMetadata episode; final PlexClient? client; final VoidCallback onTap; @@ -263,6 +264,7 @@ class _EpisodeCard extends StatelessWidget { final bool autofocus; final bool isOffline; final String? localPosterPath; + final FocusNode? focusNode; const _EpisodeCard({ required this.episode, @@ -272,17 +274,29 @@ class _EpisodeCard extends StatelessWidget { this.autofocus = false, this.isOffline = false, this.localPosterPath, + this.focusNode, }); + @override + State<_EpisodeCard> createState() => _EpisodeCardState(); +} + +class _EpisodeCardState extends State<_EpisodeCard> { + final _contextMenuKey = GlobalKey(); + + void _showContextMenu() { + _contextMenuKey.currentState?.showContextMenu(context); + } + Widget _buildEpisodeMetaRow(BuildContext context) { return Row( children: [ - if (episode.duration != null) + if (widget.episode.duration != null) Text( - formatDurationTimestamp(Duration(milliseconds: episode.duration!)), + formatDurationTimestamp(Duration(milliseconds: widget.episode.duration!)), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12), ), - if (episode.originallyAvailableAt != null) ...[ + if (widget.episode.originallyAvailableAt != null) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( @@ -291,7 +305,7 @@ class _EpisodeCard extends StatelessWidget { ), ), Text( - formatFullDate(episode.originallyAvailableAt!), + formatFullDate(widget.episode.originallyAvailableAt!), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12), ), ], @@ -302,279 +316,293 @@ class _EpisodeCard extends StatelessWidget { @override Widget build(BuildContext context) { // Hide progress when offline (not tracked) - final hasProgress = !isOffline && episode.viewOffset != null && episode.duration != null && episode.viewOffset! > 0; - final progress = hasProgress ? episode.viewOffset! / episode.duration! : 0.0; + final hasProgress = + !widget.isOffline && + widget.episode.viewOffset != null && + widget.episode.duration != null && + widget.episode.viewOffset! > 0; + final progress = hasProgress ? widget.episode.viewOffset! / widget.episode.duration! : 0.0; - return MediaContextMenu( - item: episode, - onRefresh: onRefresh, - onTap: onTap, - child: InkWell( - key: Key(episode.ratingKey), - autofocus: autofocus, - onTap: onTap, - 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.symmetric(horizontal: 16, vertical: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Episode thumbnail (16:9 aspect ratio, fixed width) - SizedBox( - width: 160, - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: AspectRatio( - aspectRatio: 16 / 9, - child: isOffline && localPosterPath != null - ? Image.file( - File(localPosterPath!), - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => const PlaceholderContainer( - child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32), - ), - ) - : episode.thumb != null - ? PlexOptimizedImage.thumb( - client: client, - imagePath: episode.thumb, - filterQuality: FilterQuality.medium, - fit: BoxFit.cover, - placeholder: (context, url) => const PlaceholderContainer(), - errorWidget: (context, url, error) => const PlaceholderContainer( - child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32), - ), - ) - : const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)), - ), - ), - - // Play overlay - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withValues(alpha: 0.2)], - ), - ), - child: Center( - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - shape: BoxShape.circle, - ), - child: const AppIcon(Symbols.play_arrow_rounded, fill: 1, color: Colors.white, size: 20), - ), - ), - ), - ), - - // Progress bar at bottom - if (hasProgress && !episode.isWatched) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(6), - bottomRight: Radius.circular(6), - ), - child: LinearProgressIndicator( - value: progress, - backgroundColor: tokens(context).outline, - minHeight: 3, - ), + return FocusableWrapper( + focusNode: widget.focusNode, + 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, + onTap: widget.onTap, + child: InkWell( + key: Key(widget.episode.ratingKey), + onTap: widget.onTap, + 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.symmetric(horizontal: 16, vertical: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode thumbnail (16:9 aspect ratio, fixed width) + SizedBox( + width: 160, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: AspectRatio( + aspectRatio: 16 / 9, + child: widget.isOffline && widget.localPosterPath != null + ? Image.file( + File(widget.localPosterPath!), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const PlaceholderContainer( + child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32), + ), + ) + : widget.episode.thumb != null + ? PlexOptimizedImage.thumb( + client: widget.client, + imagePath: widget.episode.thumb, + filterQuality: FilterQuality.medium, + fit: BoxFit.cover, + placeholder: (context, url) => const PlaceholderContainer(), + errorWidget: (context, url, error) => const PlaceholderContainer( + child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32), + ), + ) + : const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)), ), ), - if (episode.isWatched) - Positioned( - top: 4, - right: 4, + // Play overlay + Positioned.fill( child: Container( - padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: tokens(context).text, - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], + borderRadius: BorderRadius.circular(6), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.2)], + ), + ), + child: Center( + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const AppIcon(Symbols.play_arrow_rounded, fill: 1, color: Colors.white, size: 20), + ), ), - child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: 12), ), ), - ], + + // Progress bar at bottom + if (hasProgress && !widget.episode.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(6), + bottomRight: Radius.circular(6), + ), + child: LinearProgressIndicator( + value: progress, + backgroundColor: tokens(context).outline, + minHeight: 3, + ), + ), + ), + + if (widget.episode.isWatched) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: tokens(context).text, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], + ), + child: AppIcon(Symbols.check_rounded, fill: 1, color: tokens(context).bg, size: 12), + ), + ), + ], + ), ), - ), - const SizedBox(width: 12), + const SizedBox(width: 12), - // Episode info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Episode number and title with download status - Consumer( - builder: (context, downloadProvider, _) { - // Build download status icon based on state - Widget? downloadStatusIcon; + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode number and title with download status + Consumer( + builder: (context, downloadProvider, _) { + // Build download status icon based on state + Widget? downloadStatusIcon; - // Only show download status in online mode - if (!isOffline && episode.serverId != null) { - final globalKey = '${episode.serverId}:${episode.ratingKey}'; - final progress = downloadProvider.getProgress(globalKey); - final isQueueing = downloadProvider.isQueueing(globalKey); + // Only show download status in online mode + if (!widget.isOffline && widget.episode.serverId != null) { + final globalKey = '${widget.episode.serverId}:${widget.episode.ratingKey}'; + final progress = downloadProvider.getProgress(globalKey); + final isQueueing = downloadProvider.isQueueing(globalKey); - // Helper to get status-specific muted color - Color getMutedColor(Color baseColor) { - return Color.lerp( - tokens(context).textMuted, - baseColor, - 0.3, // 30% of the status color, 70% muted - )!; + // Helper to get status-specific muted color + Color getMutedColor(Color baseColor) { + return Color.lerp( + tokens(context).textMuted, + baseColor, + 0.3, // 30% of the status color, 70% muted + )!; + } + + if (isQueueing) { + // Queueing state - building queue + downloadStatusIcon = SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 1.5, color: tokens(context).textMuted), + ); + } else if (progress?.status == DownloadStatus.queued) { + // Queued state - waiting to download + downloadStatusIcon = AppIcon( + Symbols.schedule_rounded, + fill: 1, + size: 12, + color: getMutedColor(Colors.orange), + ); + } else if (progress?.status == DownloadStatus.downloading) { + // Downloading state - active download with radial progress + downloadStatusIcon = SizedBox( + width: 14, + height: 14, + child: Stack( + alignment: Alignment.center, + children: [ + // Background circle + CircularProgressIndicator( + value: 1.0, + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation( + getMutedColor(Colors.blue).withValues(alpha: 0.3), + ), + ), + // Progress circle + CircularProgressIndicator( + value: progress?.progressPercent, + strokeWidth: 1.5, + valueColor: AlwaysStoppedAnimation(getMutedColor(Colors.blue)), + ), + ], + ), + ); + } else if (progress?.status == DownloadStatus.paused) { + // Paused state - download paused + downloadStatusIcon = AppIcon( + Symbols.pause_circle_outline_rounded, + fill: 1, + size: 12, + color: getMutedColor(Colors.amber), + ); + } else if (progress?.status == DownloadStatus.failed) { + // Failed state - download failed + downloadStatusIcon = AppIcon( + Symbols.error_outline_rounded, + fill: 1, + size: 12, + color: getMutedColor(Colors.red), + ); + } else if (progress?.status == DownloadStatus.cancelled) { + // Cancelled state - download cancelled + downloadStatusIcon = AppIcon( + Symbols.cancel_rounded, + fill: 1, + size: 12, + color: getMutedColor(Colors.grey), + ); + } else if (progress?.status == DownloadStatus.completed) { + // Completed state - download complete + downloadStatusIcon = AppIcon( + Symbols.file_download_done_rounded, + fill: 1, + size: 12, + color: getMutedColor(Colors.green), + ); + } + // Note: No icon shown if not downloaded (null) } - if (isQueueing) { - // Queueing state - building queue - downloadStatusIcon = SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(strokeWidth: 1.5, color: tokens(context).textMuted), - ); - } else if (progress?.status == DownloadStatus.queued) { - // Queued state - waiting to download - downloadStatusIcon = AppIcon( - Symbols.schedule_rounded, - fill: 1, - size: 12, - color: getMutedColor(Colors.orange), - ); - } else if (progress?.status == DownloadStatus.downloading) { - // Downloading state - active download with radial progress - downloadStatusIcon = SizedBox( - width: 14, - height: 14, - child: Stack( - alignment: Alignment.center, - children: [ - // Background circle - CircularProgressIndicator( - value: 1.0, - strokeWidth: 1.5, - valueColor: AlwaysStoppedAnimation( - getMutedColor(Colors.blue).withValues(alpha: 0.3), + return Row( + children: [ + // Episode number badge + if (widget.episode.index != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + 'E${widget.episode.index}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 11, + fontWeight: FontWeight.w600, ), ), - // Progress circle - CircularProgressIndicator( - value: progress?.progressPercent, - strokeWidth: 1.5, - valueColor: AlwaysStoppedAnimation(getMutedColor(Colors.blue)), - ), - ], - ), - ); - } else if (progress?.status == DownloadStatus.paused) { - // Paused state - download paused - downloadStatusIcon = AppIcon( - Symbols.pause_circle_outline_rounded, - fill: 1, - size: 12, - color: getMutedColor(Colors.amber), - ); - } else if (progress?.status == DownloadStatus.failed) { - // Failed state - download failed - downloadStatusIcon = AppIcon( - Symbols.error_outline_rounded, - fill: 1, - size: 12, - color: getMutedColor(Colors.red), - ); - } else if (progress?.status == DownloadStatus.cancelled) { - // Cancelled state - download cancelled - downloadStatusIcon = AppIcon( - Symbols.cancel_rounded, - fill: 1, - size: 12, - color: getMutedColor(Colors.grey), - ); - } else if (progress?.status == DownloadStatus.completed) { - // Completed state - download complete - downloadStatusIcon = AppIcon( - Symbols.file_download_done_rounded, - fill: 1, - size: 12, - color: getMutedColor(Colors.green), - ); - } - // Note: No icon shown if not downloaded (null) - } - - return Row( - children: [ - // Episode number badge - if (episode.index != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(3), ), + // Download status icon (if present) + if (downloadStatusIcon != null) ...[const SizedBox(width: 6), downloadStatusIcon], + const SizedBox(width: 8), + // Episode title + Expanded( child: Text( - 'E${episode.index}', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontSize: 11, - fontWeight: FontWeight.w600, - ), + widget.episode.title, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), - // Download status icon (if present) - if (downloadStatusIcon != null) ...[const SizedBox(width: 6), downloadStatusIcon], - const SizedBox(width: 8), - // Episode title - Expanded( - child: Text( - episode.title, - style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - }, - ), - - // Summary - if (episode.summary != null && episode.summary!.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - episode.summary!, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3), - maxLines: 3, - overflow: TextOverflow.ellipsis, + ], + ); + }, ), - ], - // Metadata row (duration, watched status) - const SizedBox(height: 8), - _buildEpisodeMetaRow(context), - ], + // Summary + if (widget.episode.summary != null && widget.episode.summary!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + widget.episode.summary!, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, height: 1.3), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + + // Metadata row (duration, watched status) + const SizedBox(height: 8), + _buildEpisodeMetaRow(context), + ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 2c123812..73ce8165 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../focus/focusable_wrapper.dart'; import '../i18n/strings.g.dart'; import '../models/download_models.dart'; import '../models/plex_metadata.dart'; @@ -56,6 +57,9 @@ class DownloadTreeView extends StatefulWidget { final void Function(String globalKey)? onRetry; final void Function(String globalKey)? onCancel; final void Function(String globalKey)? onDelete; + final VoidCallback? onNavigateLeft; + final VoidCallback? onBack; + final bool suppressAutoFocus; const DownloadTreeView({ super.key, @@ -66,6 +70,9 @@ class DownloadTreeView extends StatefulWidget { this.onRetry, this.onCancel, this.onDelete, + this.onNavigateLeft, + this.onBack, + this.suppressAutoFocus = false, }); @override @@ -74,6 +81,26 @@ class DownloadTreeView extends StatefulWidget { class _DownloadTreeViewState extends State { final Set _expandedNodes = {}; + final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'DownloadTreeView_firstItem'); + + @override + void dispose() { + _firstItemFocusNode.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(DownloadTreeView oldWidget) { + super.didUpdateWidget(oldWidget); + // When suppressAutoFocus changes from true to false, focus the first item + if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _firstItemFocusNode.canRequestFocus) { + _firstItemFocusNode.requestFocus(); + } + }); + } + } @override Widget build(BuildContext context) { @@ -89,7 +116,7 @@ class _DownloadTreeViewState extends State { itemCount: flattenedNodes.length, itemBuilder: (context, index) { final item = flattenedNodes[index]; - return _buildTreeItem(item.node, item.depth); + return _buildTreeItem(item.node, item.depth, isFirst: index == 0); }, ); } @@ -319,250 +346,27 @@ class _DownloadTreeViewState extends State { } /// Build a tree item widget - Widget _buildTreeItem(DownloadTreeNode node, int depth) { - final isExpanded = _expandedNodes.contains(node.key); - final canExpand = node.hasChildren; - - return InkWell( - onTap: canExpand ? () => _toggleExpansion(node.key) : null, - child: Padding( - padding: EdgeInsets.only(left: depth * 16.0), - child: _buildNodeContent(node, isExpanded, canExpand), - ), + Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) { + return _DownloadTreeItem( + node: node, + depth: depth, + isExpanded: _expandedNodes.contains(node.key), + onToggleExpansion: () => _toggleExpansion(node.key), + onPause: widget.onPause, + onResume: widget.onResume, + onRetry: widget.onRetry, + onCancel: widget.onCancel, + onDelete: widget.onDelete, + onNavigateLeft: widget.onNavigateLeft, + onBack: widget.onBack, + rowFocusNode: isFirst ? _firstItemFocusNode : null, + autofocus: isFirst && !widget.suppressAutoFocus, + pauseAllChildren: _pauseAllChildren, + resumeAllChildren: _resumeAllChildren, + deleteAllChildren: _deleteAllChildren, ); } - /// Build the content for a node - Widget _buildNodeContent(DownloadTreeNode node, bool isExpanded, bool canExpand) { - final theme = Theme.of(context); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - // Expand/collapse icon - if (canExpand) - AppIcon(isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20) - else - const SizedBox(width: 20), - - const SizedBox(width: 8), - - // Status icon - _buildStatusIcon(node.status), - - const SizedBox(width: 12), - - // Title and info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - node.title, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: canExpand ? FontWeight.w600 : FontWeight.normal, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - - if (canExpand) ...[ - const SizedBox(height: 4), - Text( - _getNodeSummary(node), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - - // Progress bar - if (node.status == DownloadStatus.downloading || node.status == DownloadStatus.queued) ...[ - const SizedBox(height: 8), - LinearProgressIndicator( - value: node.progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, - ), - if (node.downloadProgress != null) ...[ - const SizedBox(height: 4), - Text( - '${(node.progress * 100).toStringAsFixed(1)}% - ${node.downloadProgress!.speedFormatted}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ], - ], - ), - ), - - // Actions - _buildActions(node), - ], - ), - ); - } - - /// Build status icon - Widget _buildStatusIcon(DownloadStatus status) { - IconData iconData; - Color? color; - - switch (status) { - case DownloadStatus.downloading: - iconData = Symbols.downloading_rounded; - color = Colors.blue; - break; - case DownloadStatus.queued: - iconData = Symbols.schedule_rounded; - color = Colors.orange; - break; - case DownloadStatus.paused: - iconData = Symbols.pause_circle_outline_rounded; - color = Colors.grey; - break; - case DownloadStatus.completed: - iconData = Symbols.check_circle_rounded; - color = Colors.green; - break; - case DownloadStatus.failed: - iconData = Symbols.error_rounded; - color = Colors.red; - break; - case DownloadStatus.cancelled: - iconData = Symbols.cancel_rounded; - color = Colors.grey; - break; - case DownloadStatus.partial: - iconData = Symbols.downloading_rounded; - color = Colors.orange; - break; - } - - return AppIcon(iconData, fill: 1, size: 20, color: color); - } - - /// Get summary text for container nodes (shows/seasons) - String _getNodeSummary(DownloadTreeNode node) { - final total = node.children.length; - final completed = node.completedChildrenCount; - return '$completed/$total completed'; - } - - /// Build action buttons for nodes - Widget _buildActions(DownloadTreeNode node) { - final isContainer = node.type == DownloadNodeType.show || node.type == DownloadNodeType.season; - - final actions = isContainer ? _getContainerActions(node) : _getItemActions(node); - - return Row(mainAxisSize: MainAxisSize.min, children: actions); - } - - /// Get action buttons for individual items (episodes/movies) - List _getItemActions(DownloadTreeNode node) { - final globalKey = node.key; - final status = node.status; - final actions = []; - - // Pause button for downloading items - if (status == DownloadStatus.downloading && widget.onPause != null) { - actions.add( - _buildActionButton(icon: Symbols.pause_rounded, tooltip: 'Pause', onPressed: () => widget.onPause!(globalKey)), - ); - } - - // Resume button for paused items - if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add( - _buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: 'Resume', - onPressed: () => widget.onResume!(globalKey), - ), - ); - } - - // Cancel button for downloading/queued items - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) { - actions.add( - _buildActionButton( - icon: Symbols.close_rounded, - tooltip: 'Cancel', - onPressed: () => widget.onCancel!(globalKey), - ), - ); - } - - // Retry button for failed items - if (status == DownloadStatus.failed && widget.onRetry != null) { - actions.add( - _buildActionButton( - icon: Symbols.refresh_rounded, - tooltip: t.downloads.retryDownload, - onPressed: () => widget.onRetry!(globalKey), - ), - ); - } - - // Delete button for completed/failed/cancelled items - if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && - widget.onDelete != null) { - actions.add( - _buildActionButton( - icon: Symbols.delete_rounded, - tooltip: 'Delete', - onPressed: () => widget.onDelete!(globalKey), - ), - ); - } - - return actions; - } - - /// Get action buttons for container nodes (shows/seasons) - List _getContainerActions(DownloadTreeNode node) { - final status = node.status; - final actions = []; - - // Pause all button - show if any children are downloading or queued - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) { - actions.add( - _buildActionButton(icon: Symbols.pause_rounded, tooltip: 'Pause all', onPressed: () => _pauseAllChildren(node)), - ); - } - - // Resume all button - show if container is paused - if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add( - _buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: 'Resume all', - onPressed: () => _resumeAllChildren(node), - ), - ); - } - - // Delete all button - if (widget.onDelete != null) { - actions.add( - _buildActionButton( - icon: Symbols.delete_sweep_rounded, - tooltip: 'Delete all', - onPressed: () => _deleteAllChildren(node), - ), - ); - } - - return actions; - } - - /// Build a single action button - Widget _buildActionButton({required IconData icon, required String tooltip, required VoidCallback onPressed}) { - return IconButton(icon: AppIcon(icon, fill: 1, size: 20), onPressed: onPressed, tooltip: tooltip); - } - /// Pause all active (downloading and queued) children of a container node void _pauseAllChildren(DownloadTreeNode node) { final keys = _getActiveChildKeys(node); @@ -636,3 +440,433 @@ class _FlatNode { const _FlatNode({required this.node, required this.depth}); } + +/// A single tree item with focusable row content and action buttons +class _DownloadTreeItem extends StatefulWidget { + final DownloadTreeNode node; + final int depth; + final bool isExpanded; + final VoidCallback onToggleExpansion; + final void Function(String globalKey)? onPause; + final void Function(String globalKey)? onResume; + final void Function(String globalKey)? onRetry; + final void Function(String globalKey)? onCancel; + final void Function(String globalKey)? onDelete; + final VoidCallback? onNavigateLeft; + final VoidCallback? onBack; + final FocusNode? rowFocusNode; + final bool autofocus; + final void Function(DownloadTreeNode) pauseAllChildren; + final void Function(DownloadTreeNode) resumeAllChildren; + final void Function(DownloadTreeNode) deleteAllChildren; + + const _DownloadTreeItem({ + required this.node, + required this.depth, + required this.isExpanded, + required this.onToggleExpansion, + this.onPause, + this.onResume, + this.onRetry, + this.onCancel, + this.onDelete, + this.onNavigateLeft, + this.onBack, + this.rowFocusNode, + this.autofocus = false, + required this.pauseAllChildren, + required this.resumeAllChildren, + required this.deleteAllChildren, + }); + + @override + State<_DownloadTreeItem> createState() => _DownloadTreeItemState(); +} + +class _DownloadTreeItemState extends State<_DownloadTreeItem> { + // Focus node for row content (only created if not provided externally) + FocusNode? _ownedRowFocusNode; + // Focus nodes for action buttons (up to 3 buttons max) + final List _buttonFocusNodes = []; + + FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!; + + @override + void initState() { + super.initState(); + _initRowFocusNode(); + _initButtonFocusNodes(); + } + + @override + void didUpdateWidget(_DownloadTreeItem oldWidget) { + super.didUpdateWidget(oldWidget); + // Reinitialize focus nodes if action count might have changed + if (oldWidget.node.status != widget.node.status) { + _disposeButtonFocusNodes(); + _initButtonFocusNodes(); + } + } + + void _initRowFocusNode() { + if (widget.rowFocusNode == null) { + _ownedRowFocusNode = FocusNode(debugLabel: 'download_row_${widget.node.key}'); + } + } + + void _initButtonFocusNodes() { + final actionCount = _getActionCount(); + for (int i = 0; i < actionCount; i++) { + _buttonFocusNodes.add(FocusNode(debugLabel: 'download_action_$i')); + } + } + + void _disposeButtonFocusNodes() { + for (final node in _buttonFocusNodes) { + node.dispose(); + } + _buttonFocusNodes.clear(); + } + + @override + void dispose() { + _ownedRowFocusNode?.dispose(); + _disposeButtonFocusNodes(); + super.dispose(); + } + + int _getActionCount() { + final isContainer = widget.node.type == DownloadNodeType.show || + widget.node.type == DownloadNodeType.season; + if (isContainer) { + return _getContainerActionCount(); + } + return _getItemActionCount(); + } + + int _getItemActionCount() { + int count = 0; + final status = widget.node.status; + if (status == DownloadStatus.downloading && widget.onPause != null) count++; + if (status == DownloadStatus.paused && widget.onResume != null) count++; + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && + widget.onCancel != null) count++; + if (status == DownloadStatus.failed && widget.onRetry != null) count++; + if ((status == DownloadStatus.completed || status == DownloadStatus.failed || + status == DownloadStatus.cancelled) && widget.onDelete != null) count++; + return count; + } + + int _getContainerActionCount() { + int count = 0; + final status = widget.node.status; + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && + widget.onPause != null) count++; + if (status == DownloadStatus.paused && widget.onResume != null) count++; + if (widget.onDelete != null) count++; + return count; + } + + void _focusFirstButton() { + if (_buttonFocusNodes.isNotEmpty) { + _buttonFocusNodes[0].requestFocus(); + } + } + + void _focusRow() { + _rowFocusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final canExpand = widget.node.hasChildren; + final hasActions = _buttonFocusNodes.isNotEmpty; + + return Padding( + padding: EdgeInsets.only(left: widget.depth * 16.0), + child: FocusableWrapper( + focusNode: _rowFocusNode, + autofocus: widget.autofocus, + onSelect: canExpand ? widget.onToggleExpansion : null, + onNavigateLeft: widget.onNavigateLeft, + onNavigateRight: hasActions ? _focusFirstButton : null, + onBack: widget.onBack, + borderRadius: 8.0, + disableScale: true, + useBackgroundFocus: true, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Row content + Expanded(child: _buildRowContent(theme, canExpand)), + + // Action buttons + if (hasActions) _buildActions(), + ], + ), + ), + ), + ); + } + + Widget _buildRowContent(ThemeData theme, bool canExpand) { + return Row( + children: [ + // Expand/collapse icon + if (canExpand) + AppIcon( + widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, + fill: 1, + size: 20, + ) + else + const SizedBox(width: 20), + + const SizedBox(width: 8), + + // Status icon + _buildStatusIcon(widget.node.status), + + const SizedBox(width: 12), + + // Title and info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.node.title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: canExpand ? FontWeight.w600 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + if (canExpand) ...[ + const SizedBox(height: 4), + Text( + _getNodeSummary(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + + // Progress bar + if (widget.node.status == DownloadStatus.downloading || + widget.node.status == DownloadStatus.queued) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: widget.node.progress, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + if (widget.node.downloadProgress != null) ...[ + const SizedBox(height: 4), + Text( + '${(widget.node.progress * 100).toStringAsFixed(1)}% - ${widget.node.downloadProgress!.speedFormatted}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ], + ], + ), + ), + ], + ); + } + + Widget _buildStatusIcon(DownloadStatus status) { + IconData iconData; + Color? color; + + switch (status) { + case DownloadStatus.downloading: + iconData = Symbols.downloading_rounded; + color = Colors.blue; + break; + case DownloadStatus.queued: + iconData = Symbols.schedule_rounded; + color = Colors.orange; + break; + case DownloadStatus.paused: + iconData = Symbols.pause_circle_outline_rounded; + color = Colors.grey; + break; + case DownloadStatus.completed: + iconData = Symbols.check_circle_rounded; + color = Colors.green; + break; + case DownloadStatus.failed: + iconData = Symbols.error_rounded; + color = Colors.red; + break; + case DownloadStatus.cancelled: + iconData = Symbols.cancel_rounded; + color = Colors.grey; + break; + case DownloadStatus.partial: + iconData = Symbols.downloading_rounded; + color = Colors.orange; + break; + } + + return AppIcon(iconData, fill: 1, size: 20, color: color); + } + + String _getNodeSummary() { + final total = widget.node.children.length; + final completed = widget.node.completedChildrenCount; + return '$completed/$total completed'; + } + + Widget _buildActions() { + final isContainer = widget.node.type == DownloadNodeType.show || + widget.node.type == DownloadNodeType.season; + + final actions = isContainer ? _buildContainerActions() : _buildItemActions(); + + return Row(mainAxisSize: MainAxisSize.min, children: actions); + } + + List _buildItemActions() { + final globalKey = widget.node.key; + final status = widget.node.status; + final actions = []; + int buttonIndex = 0; + + // Pause button for downloading items + if (status == DownloadStatus.downloading && widget.onPause != null) { + actions.add(_buildActionButton( + icon: Symbols.pause_rounded, + tooltip: 'Pause', + onPressed: () => widget.onPause!(globalKey), + buttonIndex: buttonIndex++, + )); + } + + // Resume button for paused items + if (status == DownloadStatus.paused && widget.onResume != null) { + actions.add(_buildActionButton( + icon: Symbols.play_arrow_rounded, + tooltip: 'Resume', + onPressed: () => widget.onResume!(globalKey), + buttonIndex: buttonIndex++, + )); + } + + // Cancel button for downloading/queued items + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && + widget.onCancel != null) { + actions.add(_buildActionButton( + icon: Symbols.close_rounded, + tooltip: 'Cancel', + onPressed: () => widget.onCancel!(globalKey), + buttonIndex: buttonIndex++, + )); + } + + // Retry button for failed items + if (status == DownloadStatus.failed && widget.onRetry != null) { + actions.add(_buildActionButton( + icon: Symbols.refresh_rounded, + tooltip: t.downloads.retryDownload, + onPressed: () => widget.onRetry!(globalKey), + buttonIndex: buttonIndex++, + )); + } + + // Delete button for completed/failed/cancelled items + if ((status == DownloadStatus.completed || status == DownloadStatus.failed || + status == DownloadStatus.cancelled) && widget.onDelete != null) { + actions.add(_buildActionButton( + icon: Symbols.delete_rounded, + tooltip: 'Delete', + onPressed: () => widget.onDelete!(globalKey), + buttonIndex: buttonIndex++, + )); + } + + return actions; + } + + List _buildContainerActions() { + final status = widget.node.status; + final actions = []; + int buttonIndex = 0; + + // Pause all button + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && + widget.onPause != null) { + actions.add(_buildActionButton( + icon: Symbols.pause_rounded, + tooltip: 'Pause all', + onPressed: () => widget.pauseAllChildren(widget.node), + buttonIndex: buttonIndex++, + )); + } + + // Resume all button + if (status == DownloadStatus.paused && widget.onResume != null) { + actions.add(_buildActionButton( + icon: Symbols.play_arrow_rounded, + tooltip: 'Resume all', + onPressed: () => widget.resumeAllChildren(widget.node), + buttonIndex: buttonIndex++, + )); + } + + // Delete all button + if (widget.onDelete != null) { + actions.add(_buildActionButton( + icon: Symbols.delete_sweep_rounded, + tooltip: 'Delete all', + onPressed: () => widget.deleteAllChildren(widget.node), + buttonIndex: buttonIndex++, + )); + } + + return actions; + } + + Widget _buildActionButton({ + required IconData icon, + required String tooltip, + required VoidCallback onPressed, + required int buttonIndex, + }) { + final isFirst = buttonIndex == 0; + final isLast = buttonIndex == _buttonFocusNodes.length - 1; + + return FocusableWrapper( + focusNode: _buttonFocusNodes[buttonIndex], + onSelect: onPressed, + onNavigateLeft: isFirst + ? _focusRow + : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(), + onNavigateRight: isLast + ? null + : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(), + onBack: widget.onBack, + borderRadius: 20.0, + disableScale: true, + useBackgroundFocus: true, + autoScroll: false, + child: Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: AppIcon(icon, fill: 1, size: 20), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/focusable_bottom_sheet.dart b/lib/widgets/focusable_bottom_sheet.dart index cee4b6d2..bcf9e1dd 100644 --- a/lib/widgets/focusable_bottom_sheet.dart +++ b/lib/widgets/focusable_bottom_sheet.dart @@ -64,6 +64,9 @@ class _FocusableBottomSheetState extends State { if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { return KeyEventResult.handled; } + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } return KeyEventResult.ignored; }, child: widget.child, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 8708f4fe..999ba2bf 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -113,6 +113,10 @@ class MediaContextMenuState extends State { if (_isContextMenuOpen) return; _isContextMenuOpen = true; + // Capture the currently focused node for restoration after menu closes + final previousFocus = FocusManager.instance.primaryFocus; + bool didNavigate = false; + final isPlaylist = widget.item is PlexPlaylist; final metadata = isPlaylist ? null : widget.item as PlexMetadata; final mediaType = isPlaylist ? null : metadata!.mediaType; @@ -257,6 +261,8 @@ class MediaContextMenuState extends State { focusFirstItem: openedFromKeyboard, ), ); + // Suppress BACK key-up to prevent it from propagating to the parent screen + BackKeyUpSuppressor.suppressBackUntilKeyUp(); } else { // Show custom focusable popup menu on larger screens // Use stored tap position or fallback to widget position @@ -276,6 +282,8 @@ class MediaContextMenuState extends State { builder: (dialogContext) => _FocusablePopupMenu(actions: menuActions, position: position, focusFirstItem: openedFromKeyboard), ); + // Suppress BACK key-up to prevent it from propagating to the parent screen + BackKeyUpSuppressor.suppressBackUntilKeyUp(); } try { @@ -353,6 +361,7 @@ class MediaContextMenuState extends State { break; case 'series': + didNavigate = true; await _navigateToRelated( context, metadata!.grandparentRatingKey, @@ -362,6 +371,7 @@ class MediaContextMenuState extends State { break; case 'season': + didNavigate = true; await _navigateToRelated( context, metadata!.parentRatingKey, @@ -404,6 +414,16 @@ class MediaContextMenuState extends State { } } finally { _isContextMenuOpen = false; + + // Restore focus to the previously focused item after the menu closes, + // but only if no navigation occurred and the focus node is still valid + if (!didNavigate && previousFocus != null && previousFocus.canRequestFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (previousFocus.canRequestFocus) { + previousFocus.requestFocus(); + } + }); + } } } @@ -477,6 +497,7 @@ class MediaContextMenuState extends State { backgroundColor: Colors.transparent, builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title), ); + BackKeyUpSuppressor.suppressBackUntilKeyUp(); } else if (context.mounted) { showErrorSnackBar(context, t.messages.fileInfoNotAvailable); } @@ -543,6 +564,7 @@ class MediaContextMenuState extends State { ), ), ); + BackKeyUpSuppressor.suppressBackUntilKeyUp(); } else { // Show popup menu on desktop selected = await showMenu( @@ -563,6 +585,7 @@ class MediaContextMenuState extends State { ); }).toList(), ); + BackKeyUpSuppressor.suppressBackUntilKeyUp(); } // Handle the submenu selection @@ -591,6 +614,7 @@ class MediaContextMenuState extends State { context: context, builder: (context) => _PlaylistSelectionDialog(playlists: playlists), ); + BackKeyUpSuppressor.suppressBackUntilKeyUp(); if (result == null || !context.mounted) return; @@ -609,6 +633,7 @@ class MediaContextMenuState extends State { labelText: t.playlists.playlistName, hintText: t.playlists.enterPlaylistName, ); + BackKeyUpSuppressor.suppressBackUntilKeyUp(); if (playlistName == null || playlistName.isEmpty || !context.mounted) { return; @@ -727,6 +752,7 @@ class MediaContextMenuState extends State { context: context, builder: (context) => _CollectionSelectionDialog(collections: collections), ); + BackKeyUpSuppressor.suppressBackUntilKeyUp(); if (result == null || !context.mounted) return; @@ -744,6 +770,7 @@ class MediaContextMenuState extends State { labelText: t.collections.collectionName, hintText: t.collections.enterCollectionName, ); + BackKeyUpSuppressor.suppressBackUntilKeyUp(); if (collectionName == null || collectionName.isEmpty || !context.mounted) { return; @@ -1230,6 +1257,9 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { return KeyEventResult.handled; } + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } return KeyEventResult.ignored; }, child: Stack(