diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart index ed0bf08f..3bc303b9 100644 --- a/lib/focus/key_event_utils.dart +++ b/lib/focus/key_event_utils.dart @@ -29,9 +29,15 @@ import 'dpad_navigator.dart'; /// ) /// ``` KeyEventResult handleBackKeyNavigation(BuildContext context, KeyEvent event, {T? result}) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { + // Handle on KeyUpEvent to prevent double-pop when returning from child screens + // (KeyDownEvent can be received by both the popping screen and the returned-to screen) + if (event is KeyUpEvent && event.logicalKey.isBackKey) { Navigator.pop(context, result); return KeyEventResult.handled; } + // Consume KeyDownEvent to prevent it from propagating but don't pop yet + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + return KeyEventResult.handled; + } return KeyEventResult.ignored; } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index e855d893..d80ea4eb 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1,13 +1,18 @@ +import 'dart:async'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; +import '../widgets/focus_builders.dart'; +import '../widgets/media_card.dart'; import '../i18n/strings.g.dart'; import '../widgets/plex_optimized_image.dart'; import '../utils/plex_image_helper.dart'; @@ -27,7 +32,6 @@ import '../utils/video_player_navigation.dart'; import '../widgets/app_bar_back_button.dart'; import '../utils/desktop_window_padding.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; -import '../widgets/focusable_media_card.dart'; import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; import '../mixins/watch_state_aware.dart'; @@ -55,6 +59,15 @@ class _MediaDetailScreenState extends State with WatchStateAw bool _watchStateChanged = false; double _scrollOffset = 0; + // Locked focus pattern for seasons + int _focusedSeasonIndex = 0; + late final FocusNode _seasonsFocusNode; + late final FocusNode _playButtonFocusNode; + Timer? _selectKeyTimer; + bool _isSelectKeyDown = false; + bool _longPressTriggered = false; + static const _longPressDuration = Duration(milliseconds: 500); + // WatchStateAware: watch the show/movie and all season ratingKeys @override Set? get watchedRatingKeys { @@ -115,6 +128,8 @@ class _MediaDetailScreenState extends State with WatchStateAw super.initState(); _scrollController = ScrollController(); _scrollController.addListener(_onScroll); + _seasonsFocusNode = FocusNode(debugLabel: 'seasons_row'); + _playButtonFocusNode = FocusNode(debugLabel: 'play_button'); _loadFullMetadata(); } @@ -128,6 +143,9 @@ class _MediaDetailScreenState extends State with WatchStateAw void dispose() { _scrollController.dispose(); _seasonsScrollController.dispose(); + _seasonsFocusNode.dispose(); + _playButtonFocusNode.dispose(); + _selectKeyTimer?.cancel(); super.dispose(); } @@ -213,6 +231,7 @@ class _MediaDetailScreenState extends State with WatchStateAw SizedBox( height: 48, child: FilledButton( + focusNode: _playButtonFocusNode, autofocus: InputModeTracker.isKeyboardMode(context), onPressed: onPlayPressed, style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16)), @@ -749,7 +768,118 @@ class _MediaDetailScreenState extends State with WatchStateAw } } + /// Scroll season list to center the item at the given index + void _scrollSeasonToIndex(int index, {bool animate = true}) { + if (!_seasonsScrollController.hasClients) return; + + final screenWidth = MediaQuery.of(context).size.width; + final cardWidth = screenWidth >= 1400 + ? 220.0 + : screenWidth >= 900 + ? 200.0 + : screenWidth >= 700 + ? 190.0 + : 160.0; + final itemExtent = cardWidth + 4; // card + padding + + final viewport = _seasonsScrollController.position.viewportDimension; + final targetCenter = 12 + (index * itemExtent) + (itemExtent / 2); // 12 = leading padding + final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, _seasonsScrollController.position.maxScrollExtent); + + if (animate) { + _seasonsScrollController.animateTo( + desiredOffset, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } else { + _seasonsScrollController.jumpTo(desiredOffset); + } + } + + /// Handle key events for the seasons row (locked focus pattern) + KeyEventResult _handleSeasonsKeyEvent(FocusNode node, KeyEvent event) { + final key = event.logicalKey; + + // Let back key propagate to parent Focus handler + if (key.isBackKey) { + return KeyEventResult.ignored; + } + + // Handle SELECT with long-press detection + if (key.isSelectKey) { + if (event is KeyDownEvent) { + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTriggered = false; + _selectKeyTimer?.cancel(); + _selectKeyTimer = Timer(_longPressDuration, () { + if (!mounted) return; + if (_isSelectKeyDown) { + _longPressTriggered = true; + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + // Long-press: could show context menu if needed + } + }); + } + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + return KeyEventResult.handled; + } else if (event is KeyUpEvent) { + final timerWasActive = _selectKeyTimer?.isActive ?? false; + _selectKeyTimer?.cancel(); + if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) { + // Short tap: navigate to season + if (_focusedSeasonIndex < _seasons.length) { + _navigateToSeason(_seasons[_focusedSeasonIndex]); + } + } + _isSelectKeyDown = false; + _longPressTriggered = false; + return KeyEventResult.handled; + } + } + + if (!event.isActionable) return KeyEventResult.ignored; + if (_seasons.isEmpty) return KeyEventResult.ignored; + + // LEFT: previous season + if (key.isLeftKey) { + if (_focusedSeasonIndex > 0) { + _focusedSeasonIndex--; + _scrollSeasonToIndex(_focusedSeasonIndex); + setState(() {}); + } + return KeyEventResult.handled; + } + + // RIGHT: next season + if (key.isRightKey) { + if (_focusedSeasonIndex < _seasons.length - 1) { + _focusedSeasonIndex++; + _scrollSeasonToIndex(_focusedSeasonIndex); + setState(() {}); + } + return KeyEventResult.handled; + } + + // UP: scroll to top and focus play button + if (key.isUpKey) { + _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + _playButtonFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + // DOWN: consume (nothing below seasons to focus) + if (key.isDownKey) { + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + /// Build horizontal seasons list for larger screens (>=600px) + /// Uses locked focus pattern for D-pad centered scrolling Widget _buildHorizontalSeasons() { final screenWidth = MediaQuery.of(context).size.width; final cardWidth = screenWidth >= 1400 @@ -762,32 +892,45 @@ class _MediaDetailScreenState extends State with WatchStateAw final posterHeight = (cardWidth - 16) * 1.5; final containerHeight = posterHeight + 66; - return SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - controller: _seasonsScrollController, - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(vertical: 5), - itemCount: _seasons.length, - itemBuilder: (context, index) { - final season = _seasons[index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: FocusableMediaCard( - item: season, - width: cardWidth, - height: posterHeight, - forceGridMode: true, - isOffline: widget.isOffline, - onRefresh: (_) { - _watchStateChanged = true; - _updateWatchState(); - }, - ), - ); - }, + final hasFocus = _seasonsFocusNode.hasFocus; + + return Focus( + focusNode: _seasonsFocusNode, + onKeyEvent: _handleSeasonsKeyEvent, + child: SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + controller: _seasonsScrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 12), + itemCount: _seasons.length, + itemBuilder: (context, index) { + final season = _seasons[index]; + final isFocused = hasFocus && index == _focusedSeasonIndex; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: () => _navigateToSeason(season), + child: MediaCard( + item: season, + width: cardWidth, + height: posterHeight, + forceGridMode: true, + isOffline: widget.isOffline, + onRefresh: (_) { + _watchStateChanged = true; + _updateWatchState(); + }, + ), + ), + ); + }, + ), ), ), );