fix: don't focus episode when tap navigating
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user