From 8b88862a10d6be5eab3e3aac13085331c0b0a2ab Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:33:56 +0100 Subject: [PATCH] fix: don't focus episode when tap navigating --- lib/screens/media_detail_screen.dart | 11 +- lib/screens/season_detail_screen.dart | 14 +- lib/widgets/folder_tree_item.dart | 181 ++++++++++++++++---------- lib/widgets/folder_tree_view.dart | 13 +- lib/widgets/media_card.dart | 25 ++-- lib/widgets/media_context_menu.dart | 5 +- 6 files changed, 155 insertions(+), 94 deletions(-) diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 557303c7..17848526 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -938,7 +938,7 @@ class _MediaDetailScreenState extends State { return _FocusableSeasonCard( season: season, client: _getClientForMetadata(context), - onTap: () async { + onTap: ({bool isKeyboard = false}) async { final watchStateChanged = await Navigator.push( context, @@ -946,6 +946,7 @@ class _MediaDetailScreenState extends State { builder: (context) => SeasonDetailScreen( season: season, + focusFirstEpisode: isKeyboard, ), ), ); @@ -1166,7 +1167,7 @@ class _MediaDetailScreenState extends State { class _FocusableSeasonCard extends StatefulWidget { final PlexMetadata season; final PlexClient client; - final VoidCallback onTap; + final void Function({bool isKeyboard}) onTap; final VoidCallback onRefresh; const _FocusableSeasonCard({ @@ -1187,7 +1188,7 @@ class _FocusableSeasonCardState extends State<_FocusableSeasonCard> final _contextMenuKey = GlobalKey(); @override - void onKeyboardTap() => widget.onTap(); + void onKeyboardTap() => widget.onTap(isKeyboard: true); @override void onKeyboardLongPress() { @@ -1245,14 +1246,14 @@ class _FocusableSeasonCardState extends State<_FocusableSeasonCard> key: _contextMenuKey, item: season, onRefresh: (ratingKey) => widget.onRefresh(), - onTap: widget.onTap, + onTap: () => widget.onTap(isKeyboard: false), child: Semantics( label: "media-season-${season.ratingKey}", identifier: "media-season-${season.ratingKey}", button: true, hint: "Tap to view ${season.title}", child: InkWell( - onTap: widget.onTap, + onTap: () => widget.onTap(isKeyboard: false), focusColor: Colors.transparent, child: Padding( padding: const EdgeInsets.all(12), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index c1073a95..45c0ab94 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -17,7 +17,15 @@ import '../i18n/strings.g.dart'; class SeasonDetailScreen extends StatefulWidget { final PlexMetadata season; - const SeasonDetailScreen({super.key, required this.season}); + /// Whether to focus the first episode after loading. + /// Should be true when navigating via keyboard, false for mouse/tap. + final bool focusFirstEpisode; + + const SeasonDetailScreen({ + super.key, + required this.season, + this.focusFirstEpisode = false, + }); @override State createState() => _SeasonDetailScreenState(); @@ -72,8 +80,8 @@ class _SeasonDetailScreenState extends State _isLoadingEpisodes = false; }); - // Focus the first episode after loading - if (episodes.isNotEmpty) { + // Focus the first episode after loading (only if keyboard navigation) + if (episodes.isNotEmpty && widget.focusFirstEpisode) { WidgetsBinding.instance.addPostFrameCallback((_) { _firstEpisodeFocusNode.requestFocus(); }); diff --git a/lib/widgets/folder_tree_item.dart b/lib/widgets/folder_tree_item.dart index 9b5d0c12..cfe8c962 100644 --- a/lib/widgets/folder_tree_item.dart +++ b/lib/widgets/folder_tree_item.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../models/plex_metadata.dart'; /// Individual item in the folder tree /// Can be either a folder (expandable) or a file (tappable) -class FolderTreeItem extends StatelessWidget { +class FolderTreeItem extends StatefulWidget { final PlexMetadata item; final int depth; final bool isExpanded; final bool isFolder; - final VoidCallback? onTap; + final void Function({bool isKeyboard})? onTap; final VoidCallback? onExpand; final bool isLoading; @@ -23,13 +24,20 @@ class FolderTreeItem extends StatelessWidget { this.isLoading = false, }); + @override + State createState() => _FolderTreeItemState(); +} + +class _FolderTreeItemState extends State { + bool _isKeyboardActivation = false; + IconData _getIcon() { - if (isFolder) { + if (widget.isFolder) { return Icons.folder; } // File icons based on type - final type = item.type.toLowerCase(); + final type = widget.item.type.toLowerCase(); switch (type) { case 'movie': return Icons.movie; @@ -46,81 +54,110 @@ class FolderTreeItem extends StatelessWidget { } } + bool _isActivationKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.space || + key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.gameButtonA; + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && _isActivationKey(event.logicalKey)) { + _isKeyboardActivation = true; + return KeyEventResult.ignored; // Let InkWell handle the activation + } + return KeyEventResult.ignored; + } + + void _handleTap() { + if (widget.isFolder) { + widget.onExpand?.call(); + } else { + widget.onTap?.call(isKeyboard: _isKeyboardActivation); + } + _isKeyboardActivation = false; + } + @override Widget build(BuildContext context) { - final indentation = depth * 24.0; + final indentation = widget.depth * 24.0; - return InkWell( - onTap: isFolder ? onExpand : onTap, - child: Container( - padding: EdgeInsets.only( - left: 16.0 + indentation, - right: 16.0, - top: 12.0, - bottom: 12.0, - ), - child: Row( - children: [ - // Expand/collapse icon for folders - if (isFolder) - SizedBox( - width: 24, - child: isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Icon( - isExpanded - ? Icons.keyboard_arrow_down - : Icons.keyboard_arrow_right, - size: 20, - ), - ) - else - const SizedBox(width: 24), + return Focus( + onKeyEvent: _handleKeyEvent, + child: InkWell( + onTap: _handleTap, + child: Container( + padding: EdgeInsets.only( + left: 16.0 + indentation, + right: 16.0, + top: 12.0, + bottom: 12.0, + ), + child: Row( + children: [ + // Expand/collapse icon for folders + if (widget.isFolder) + SizedBox( + width: 24, + child: widget.isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + widget.isExpanded + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_right, + size: 20, + ), + ) + else + const SizedBox(width: 24), - const SizedBox(width: 8), + const SizedBox(width: 8), - // File/folder icon - Icon( - _getIcon(), - size: 20, - color: isFolder - ? Theme.of(context).colorScheme.primary - : Theme.of( + // File/folder icon + Icon( + _getIcon(), + size: 20, + color: widget.isFolder + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + + const SizedBox(width: 12), + + // Item title + Expanded( + child: Text( + widget.item.title, + style: TextStyle( + fontSize: 14, + fontWeight: widget.isFolder + ? FontWeight.w500 + : FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // Additional metadata for files + if (!widget.isFolder && widget.item.year != null) + Text( + widget.item.year.toString(), + style: TextStyle( + fontSize: 12, + color: Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - - const SizedBox(width: 12), - - // Item title - Expanded( - child: Text( - item.title, - style: TextStyle( - fontSize: 14, - fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - - // Additional metadata for files - if (!isFolder && item.year != null) - Text( - item.year.toString(), - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], + ], + ), ), ), ); diff --git a/lib/widgets/folder_tree_view.dart b/lib/widgets/folder_tree_view.dart index 02137234..818cf76c 100644 --- a/lib/widgets/folder_tree_view.dart +++ b/lib/widgets/folder_tree_view.dart @@ -150,7 +150,10 @@ class _FolderTreeViewState extends State { } } - Future _handleItemTap(PlexMetadata item) async { + Future _handleItemTap( + PlexMetadata item, { + bool isKeyboard = false, + }) async { final itemType = item.type.toLowerCase(); // For episodes, start playback directly @@ -163,7 +166,8 @@ class _FolderTreeViewState extends State { await Navigator.push( context, MaterialPageRoute( - builder: (context) => SeasonDetailScreen(season: item), + builder: (context) => + SeasonDetailScreen(season: item, focusFirstEpisode: isKeyboard), ), ); widget.onRefresh?.call(item.ratingKey); @@ -215,7 +219,10 @@ class _FolderTreeViewState extends State { isExpanded: isExpanded, isLoading: isLoading, onExpand: isFolder ? () => _toggleFolder(item) : null, - onTap: !isFolder ? () => _handleItemTap(item) : null, + onTap: !isFolder + ? ({bool isKeyboard = false}) => + _handleItemTap(item, isKeyboard: isKeyboard) + : null, ), ); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 95a8da03..e624bd25 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -116,7 +116,7 @@ class _MediaCardState extends State { return baseLabel; } - void _handleTap(BuildContext context) async { + void _handleTap(BuildContext context, {bool isKeyboard = false}) async { // Handle playlists if (widget.item is PlexPlaylist) { await Navigator.push( @@ -175,7 +175,10 @@ class _MediaCardState extends State { await Navigator.push( context, MaterialPageRoute( - builder: (context) => SeasonDetailScreen(season: widget.item), + builder: (context) => SeasonDetailScreen( + season: widget.item, + focusFirstEpisode: isKeyboard, + ), ), ); // Season screen doesn't return a refresh flag, but we can refresh anyway @@ -210,7 +213,8 @@ class _MediaCardState extends State { width: widget.width, height: widget.height, semanticLabel: semanticLabel, - onTap: () => _handleTap(context), + onTap: ({bool isKeyboard = false}) => + _handleTap(context, isKeyboard: isKeyboard), onLongPress: _showContextMenu, focusNode: widget.focusNode, hubId: widget.hubId, @@ -219,7 +223,8 @@ class _MediaCardState extends State { : _MediaCardList( item: widget.item, semanticLabel: semanticLabel, - onTap: () => _handleTap(context), + onTap: ({bool isKeyboard = false}) => + _handleTap(context, isKeyboard: isKeyboard), onLongPress: _showContextMenu, density: settingsProvider.libraryDensity, ); @@ -245,7 +250,7 @@ class _MediaCardGrid extends StatefulWidget { final double? width; final double? height; final String semanticLabel; - final VoidCallback onTap; + final void Function({bool isKeyboard}) onTap; final VoidCallback onLongPress; /// External FocusNode for hub navigation (provided by HubSection) @@ -279,7 +284,7 @@ class _MediaCardGridState extends State<_MediaCardGrid> bool _isFocused = false; @override - void onKeyboardTap() => widget.onTap(); + void onKeyboardTap() => widget.onTap(isKeyboard: true); @override void onKeyboardLongPress() => widget.onLongPress(); @@ -438,7 +443,7 @@ class _MediaCardGridState extends State<_MediaCardGrid> label: widget.semanticLabel, button: true, child: InkWell( - onTap: widget.onTap, + onTap: () => widget.onTap(isKeyboard: false), borderRadius: BorderRadius.circular(8), focusColor: Colors.transparent, // We use our own focus indicator child: Padding( @@ -590,7 +595,7 @@ class _MediaCardGridState extends State<_MediaCardGrid> class _MediaCardList extends StatefulWidget { final dynamic item; // Can be PlexMetadata or PlexPlaylist final String semanticLabel; - final VoidCallback onTap; + final void Function({bool isKeyboard}) onTap; final VoidCallback onLongPress; final LibraryDensity density; @@ -609,7 +614,7 @@ class _MediaCardList extends StatefulWidget { class _MediaCardListState extends State<_MediaCardList> with KeyboardLongPressMixin { @override - void onKeyboardTap() => widget.onTap(); + void onKeyboardTap() => widget.onTap(isKeyboard: true); @override void onKeyboardLongPress() => widget.onLongPress(); @@ -832,7 +837,7 @@ class _MediaCardListState extends State<_MediaCardList> label: widget.semanticLabel, button: true, child: InkWell( - onTap: widget.onTap, + onTap: () => widget.onTap(isKeyboard: false), borderRadius: BorderRadius.circular(8), focusColor: Colors.transparent, // We use our own focus indicator child: Padding( diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 892fe333..a1541995 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -371,7 +371,10 @@ class MediaContextMenuState extends State { await _navigateToRelated( context, metadata!.parentRatingKey, - (metadata) => SeasonDetailScreen(season: metadata), + (metadata) => SeasonDetailScreen( + season: metadata, + focusFirstEpisode: _openedFromKeyboard, + ), t.messages.errorLoadingSeason, ); break;