@@ -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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user