fix: focus media detail info rows

close #758
This commit is contained in:
edde746
2026-04-17 05:23:18 +02:00
parent dec2e1c9fc
commit f8132e7bdb
+75 -11
View File
@@ -138,6 +138,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final _castSectionKey = GlobalKey(); final _castSectionKey = GlobalKey();
final _seasonsSectionKey = GlobalKey(); final _seasonsSectionKey = GlobalKey();
// Focus target for the trailing info rows (studio / contentRating)
late final FocusNode _infoRowsFocusNode;
final _infoRowsSectionKey = GlobalKey();
@override @override
PlexMetadata get serverBoundMetadata => widget.metadata; PlexMetadata get serverBoundMetadata => widget.metadata;
@@ -358,6 +362,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip'); _ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip');
_overviewFocusNode = FocusNode(debugLabel: 'overview'); _overviewFocusNode = FocusNode(debugLabel: 'overview');
_castFocusNode = FocusNode(debugLabel: 'cast_row'); _castFocusNode = FocusNode(debugLabel: 'cast_row');
_infoRowsFocusNode = FocusNode(debugLabel: 'info_rows');
_loadFullMetadata(); _loadFullMetadata();
} }
@@ -375,6 +380,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_ratingChipFocusNode.dispose(); _ratingChipFocusNode.dispose();
_overviewFocusNode.dispose(); _overviewFocusNode.dispose();
_castFocusNode.dispose(); _castFocusNode.dispose();
_infoRowsFocusNode.dispose();
_castScrollController.dispose(); _castScrollController.dispose();
_selectKeyTimer?.cancel(); _selectKeyTimer?.cancel();
for (final node in _seasonTabFocusNodes) { for (final node in _seasonTabFocusNodes) {
@@ -1639,6 +1645,29 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} }
} }
bool get _hasInfoRows {
final metadata = _fullMetadata ?? widget.metadata;
return metadata.studio != null || metadata.contentRating != null;
}
/// Focus the trailing info rows (studio / contentRating) and scroll them into view.
void _focusInfoRows() {
_infoRowsFocusNode.requestFocus();
_scrollSectionIntoView(_infoRowsSectionKey);
}
/// Focus the first visible focusable section above info rows: related hubs → extras → cast → …
void _focusSectionAboveInfoRows() {
if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.last.currentState?.requestFocusFromMemory();
} else if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey);
} else {
_focusSectionAboveExtras();
}
}
/// Scroll the main scroll view so the section with the given key is centered /// Scroll the main scroll view so the section with the given key is centered
void _scrollSectionIntoView(GlobalKey key) { void _scrollSectionIntoView(GlobalKey key) {
scrollContextToCenter(key.currentContext); scrollContextToCenter(key.currentContext);
@@ -1740,6 +1769,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_scrollSectionIntoView(_extrasSectionKey); _scrollSectionIntoView(_extrasSectionKey);
} else if (_relatedHubs.isNotEmpty) { } else if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory(); _relatedHubKeys.first.currentState?.requestFocusFromMemory();
} else if (_hasInfoRows) {
_focusInfoRows();
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1913,10 +1944,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// DOWN: related hubs → consume // DOWN: related hubs → info rows → consume
if (key.isDownKey) { if (key.isDownKey) {
if (_relatedHubs.isNotEmpty) { if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory(); _relatedHubKeys.first.currentState?.requestFocusFromMemory();
} else if (_hasInfoRows) {
_focusInfoRows();
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1962,13 +1995,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// DOWN: extras → related hubs → consume // DOWN: extras → related hubs → info rows → consume
if (key.isDownKey) { if (key.isDownKey) {
if (_extras != null && _extras!.isNotEmpty) { if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus(); _extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey); _scrollSectionIntoView(_extrasSectionKey);
} else if (_relatedHubs.isNotEmpty) { } else if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory(); _relatedHubKeys.first.currentState?.requestFocusFromMemory();
} else if (_hasInfoRows) {
_focusInfoRows();
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -2001,6 +2036,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1;
if (targetIndex < 0 || targetIndex >= _relatedHubKeys.length) { if (targetIndex < 0 || targetIndex >= _relatedHubKeys.length) {
if (!isUp && _hasInfoRows) _focusInfoRows();
return true; // at boundary, consume return true; // at boundary, consume
} }
@@ -2008,6 +2044,22 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return true; return true;
} }
/// Handle key events for the trailing info rows (studio / contentRating).
/// UP returns to the previous focusable section; all other directions consume.
KeyEventResult _handleInfoRowsKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (key.isBackKey) return KeyEventResult.ignored;
if (!event.isActionable) return KeyEventResult.ignored;
if (key.isUpKey) {
_focusSectionAboveInfoRows();
return KeyEventResult.handled;
}
// DOWN / LEFT / RIGHT / SELECT: consume — info rows are the terminal row.
return KeyEventResult.handled;
}
IconData _getRelatedHubIcon(PlexHub hub) { IconData _getRelatedHubIcon(PlexHub hub) {
final lower = hub.title.toLowerCase(); final lower = hub.title.toLowerCase();
if (lower.contains('collection')) return Symbols.video_library_rounded; if (lower.contains('collection')) return Symbols.video_library_rounded;
@@ -2753,15 +2805,27 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
// Additional info // Additional info — wrapped in Focus so DPAD DOWN from the
if (metadata.studio != null) ...[ // last focusable section lands here and scrolls it into view.
_buildInfoRow(t.discover.studio, metadata.studio!), if (_hasInfoRows)
const SizedBox(height: 12), Focus(
], focusNode: _infoRowsFocusNode,
if (metadata.contentRating != null) ...[ onKeyEvent: _handleInfoRowsKeyEvent,
_buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)), child: Column(
const SizedBox(height: 12), key: _infoRowsSectionKey,
], crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (metadata.studio != null) ...[
_buildInfoRow(t.discover.studio, metadata.studio!),
const SizedBox(height: 12),
],
if (metadata.contentRating != null) ...[
_buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)),
const SizedBox(height: 12),
],
],
),
),
], ],
), ),
), ),