fix: don't focus episode when tap navigating

This commit is contained in:
edde746
2025-12-03 12:34:12 +01:00
parent 09860f81c8
commit 8b88862a10
6 changed files with 155 additions and 94 deletions
+6 -5
View File
@@ -938,7 +938,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
return _FocusableSeasonCard(
season: season,
client: _getClientForMetadata(context),
onTap: () async {
onTap: ({bool isKeyboard = false}) async {
final watchStateChanged =
await Navigator.push<bool>(
context,
@@ -946,6 +946,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
builder: (context) =>
SeasonDetailScreen(
season: season,
focusFirstEpisode: isKeyboard,
),
),
);
@@ -1166,7 +1167,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
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<MediaContextMenuState>();
@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),
+11 -3
View File
@@ -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<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
@@ -72,8 +80,8 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
_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();
});
+109 -72
View File
@@ -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<FolderTreeItem> createState() => _FolderTreeItemState();
}
class _FolderTreeItemState extends State<FolderTreeItem> {
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),
),
),
],
],
),
),
),
);
+10 -3
View File
@@ -150,7 +150,10 @@ class _FolderTreeViewState extends State<FolderTreeView> {
}
}
Future<void> _handleItemTap(PlexMetadata item) async {
Future<void> _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<FolderTreeView> {
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<FolderTreeView> {
isExpanded: isExpanded,
isLoading: isLoading,
onExpand: isFolder ? () => _toggleFolder(item) : null,
onTap: !isFolder ? () => _handleItemTap(item) : null,
onTap: !isFolder
? ({bool isKeyboard = false}) =>
_handleItemTap(item, isKeyboard: isKeyboard)
: null,
),
);
+15 -10
View File
@@ -116,7 +116,7 @@ class _MediaCardState extends State<MediaCard> {
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<MediaCard> {
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<MediaCard> {
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<MediaCard> {
: _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(
+4 -1
View File
@@ -371,7 +371,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
await _navigateToRelated(
context,
metadata!.parentRatingKey,
(metadata) => SeasonDetailScreen(season: metadata),
(metadata) => SeasonDetailScreen(
season: metadata,
focusFirstEpisode: _openedFromKeyboard,
),
t.messages.errorLoadingSeason,
);
break;