Compare commits

...
Author SHA1 Message Date
edde746 703df6a561 refactor(focus): consolidate Back behind PopScope with per-owner press gates
One TV Back press could act twice: a KeyDown-fired action (Apple TV, a TV
text field under its IME, Flutter's own dialog dismissal) popped a route or
moved focus, and the KeyUp then landed on a different focus chain whose
handler acted again. Eight mechanisms had grown around that: a global
one-shot frame marker, a KeyUp suppressor armed from a NavigatorObserver on
both navigators plus a hardware-phase press tracker, per-screen
_suppressBackAfterPop flags driven by didPopNext, a backHandledByKeyEvent
flag copied between screens, ~40 handleBackKeyAction sites with an Apple TV
special case re-implemented by hand, PopScopes disabled on Android keyboard
mode to avoid a double pop, and app-level DismissIntent bindings.

The Flutter Android embedding only redispatches a Back key into
onBackPressed -> popRoute when the framework reports it unhandled, so a
handled key never produces a system pop and the only double is in-framework.
Back now has two owners. A focused control that consumes Back before the
route (text field, sheet, player strip, reorder mode, content -> sidebar)
holds a BackPressGate: it consumes the press, acts on the platform's phase,
and ignores a KeyUp whose KeyDown it did not see. Everything else bubbles to
NavigatorBackHandler, installed once around each navigator, which turns the
press into Navigator.maybePop on the route's synchronous popDisposition, so
a screen's back policy lives in its PopScope for key and system back alike.
An unowned KeyUp is swallowed there instead of reaching the platform; a
press racing a push is swallowed rather than popping the new route; a press
at the true root with nothing to pop is left to the platform.

Deleted: BackKeyCoordinator, BackKeyUpSuppressor, BackKeyPressTracker,
BackKeySuppressorObserver, handleBackKeyAction, handleBackKeyNavigation,
InputModeTracker.shouldBlockSystemBack, both _suppressBackAfterPop copies,
both backHandledByKeyEvent copies, OverlaySheetHost.onSystemBack (now
onBack, the single policy), the goBack/browserBack/gameButtonB DismissIntent
bindings, and the route-popping Focus wrappers on screens whose Back only
popped.
2026-09-05 21:30:18 +02:00
edde746 111af97a61 refactor(focus): route synthetic keys through the framework key pipeline
Gamepad, companion-remote and Siri Remote input was dispatched by walking
FocusNode.parent by hand, so it never reached HardwareKeyboard: pressed-key
state stayed stale, the eight hardware handlers (input-mode tracking, media
transport, the hotkey recorder, the Steam duplicate guard) never saw it, and
a synthetic release went to the node that took the press rather than to the
current focus like a hardware release does.

Synthetic events now enter ui.PlatformDispatcher.onKeyData, the engine's own
entry point, marked synthesized so the framework dispatches them at once.
They run the complete pipeline a hardware key does. The stream is
regularized against the framework's pressed state (a down for a held key is
a repeat, an up for an unheld key is dropped), a disconnecting source
releases what it still holds, and the Siri adapter no longer has to announce
itself to the input-mode tracker by hand.
2026-09-05 21:30:18 +02:00
64 changed files with 1948 additions and 1672 deletions
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Which event of a physical Back press performs the action.
///
/// Verified delivery model (Flutter 3.47.1 engine sources, stage 0 of the focus
/// consolidation):
///
/// * Android: `FlutterView.dispatchKeyEvent` claims every key and only
/// redispatches it to the Activity — where `onKeyUp(KEYCODE_BACK)` becomes
/// `onBackPressed` → `popRoute` — when the framework reports the event
/// **unhandled**. A Back key the focus system handles therefore never
/// produces a system pop; key-first ordering is structural, not timing.
/// * tvOS (custom engine): Menu arrives as an engine-synthesized `escape`
/// press. The KeyUp is not delivered reliably, so the action runs on KeyDown.
/// * Desktop: `escape` / `browserBack` are plain key presses. There is no
/// system back.
/// * Handheld gestures (iOS swipe, Android 13+) arrive only as `popRoute` and
/// never as key events.
///
/// The one remaining way a single press can act twice is entirely inside the
/// framework: an action that runs on KeyDown (Apple TV, a TV text field whose
/// IME would swallow the KeyUp) moves focus or pops a route, and the KeyUp is
/// then delivered to a *different* focus chain. [BackPressGate] closes that by
/// only acting on a KeyUp whose KeyDown it saw itself.
enum BackPhase { keyDown, keyUp }
/// The phase on which a Back handler acts for [event], unless the owner has a
/// reason of its own (a TV text field under a native IME passes
/// [BackPhase.keyDown] explicitly).
BackPhase backPhaseFor(KeyEvent event) => PlatformDetector.isAppleTV() ? BackPhase.keyDown : BackPhase.keyUp;
/// Per-owner state for a Back key press: acts once per physical press, on the
/// configured phase, and never on a KeyUp whose KeyDown it did not see.
///
/// Owners are the widgets that consume Back *before* the route stack — a text
/// field dismissing its IME, a sheet closing a nested page, the player hiding
/// its content strip, a screen moving focus from content to the sidebar. Each
/// owner holds one gate; it costs two booleans. Everything that is not such an
/// owner lets the press bubble to [NavigatorBackHandler], which turns it into
/// `Navigator.maybePop` so the route's `PopScope` is the single place a screen
/// decides its back policy for both key and system back.
///
/// Results:
/// * KeyDown → `handled`, press armed; the action runs now when the phase is
/// [BackPhase.keyDown].
/// * KeyRepeat → `handled` while armed, otherwise `ignored`.
/// * KeyUp of an armed press → `handled`; the action runs now when the phase is
/// [BackPhase.keyUp].
/// * KeyUp without an armed press (the press started on another focus chain)
/// → `ignored`, so it bubbles to the navigator handler, which swallows it.
/// Returning `handled` here would be wrong for a press the owner declined.
/// * A KeyDown while still armed (the previous KeyUp was swallowed off-app,
/// e.g. by a closing TV IME) is a fresh press and re-arms.
class BackPressGate {
bool _armed = false;
bool _fired = false;
/// Whether a press is in flight between its KeyDown and KeyUp.
bool get isArmed => _armed;
KeyEventResult handle(KeyEvent event, VoidCallback onBack, {BackPhase? phase}) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
final effectivePhase = phase ?? backPhaseFor(event);
if (event is KeyDownEvent) {
_armed = true;
_fired = effectivePhase == BackPhase.keyDown;
if (_fired) onBack();
return KeyEventResult.handled;
}
if (event is KeyRepeatEvent) {
return _armed ? KeyEventResult.handled : KeyEventResult.ignored;
}
if (event is KeyUpEvent) {
if (!_armed) return KeyEventResult.ignored;
_armed = false;
final fired = _fired;
_fired = false;
if (!fired) onBack();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Forget an in-flight press. Call when the owner leaves the focus chain
/// mid-press and must not act on the eventual KeyUp.
void reset() {
_armed = false;
_fired = false;
}
}
/// A non-focusable [Focus] that consumes Back for its subtree with its own
/// [BackPressGate], for owners that are not already a `State` holding a gate:
/// a sheet sub-page returning to its parent, a screen region whose Back moves
/// focus rather than popping the route.
///
/// A route's own back policy does not belong here — put it in `PopScope`, where
/// [NavigatorBackHandler] delivers key back and the platform delivers system
/// back.
class BackKeyOwner extends StatefulWidget {
const BackKeyOwner({super.key, required this.onBack, this.phase, required this.child});
final VoidCallback onBack;
/// Overrides [backPhaseFor] for this owner.
final BackPhase? phase;
final Widget child;
@override
State<BackKeyOwner> createState() => _BackKeyOwnerState();
}
class _BackKeyOwnerState extends State<BackKeyOwner> {
final BackPressGate _gate = BackPressGate();
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
return _gate.handle(event, widget.onBack, phase: widget.phase);
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
debugLabel: 'BackKeyOwner',
onKeyEvent: _handleKeyEvent,
child: widget.child,
);
}
}
+5 -69
View File
@@ -71,10 +71,9 @@ extension DpadKeyExtension on LogicalKeyboardKey {
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
}
/// Base class for suppressing key events after a key category triggers an
/// action (e.g. opening a sheet). While suppressed, every event of the matched
/// key category is consumed — including a [KeyDownEvent] when
/// [consumeKeyDowns] is set, because an armer running in the
/// Suppresses SELECT key events after a select press already acted (e.g. a
/// long press opened a sheet). While suppressed, every select event is
/// consumed — including a [KeyDownEvent], because an armer running in the
/// [HardwareKeyboard] handler phase (the hotkey recorder) arms against the
/// very KeyDown that is then re-dispatched through the focus tree.
///
@@ -84,19 +83,10 @@ extension DpadKeyExtension on LogicalKeyboardKey {
/// focus-phase consumers still swallow it — which guarantees a stale armed
/// state (a KeyUp delivered to a focus chain that never consulted us) can
/// never swallow the next press.
///
/// Armers that target an in-flight KeyUp ([consumeKeyDowns] unset) treat a
/// matching [KeyDownEvent] as proof the suppressed press already ended without
/// its KeyUp reaching us (e.g. a closing TV IME session ate it): the armed
/// state is cleared and the fresh press passes through unconsumed.
class _KeyUpSuppressor {
final bool Function(LogicalKeyboardKey) _keyMatcher;
/// Whether a matching [KeyDownEvent] is consumed while armed. See the class
/// documentation for why armers targeting an in-flight KeyUp must unset it.
final bool consumeKeyDowns;
_KeyUpSuppressor(this._keyMatcher, {this.consumeKeyDowns = true});
_KeyUpSuppressor(this._keyMatcher);
bool _suppressed = false;
@@ -125,18 +115,11 @@ class _KeyUpSuppressor {
void clearSuppression() => _suppressed = false;
/// Returns `true` (consumed) when the event belongs to the matched key
/// category and suppression is active. Clears suppression on [KeyUpEvent],
/// and on a stale [KeyDownEvent] when [consumeKeyDowns] is unset.
/// category and suppression is active. Clears suppression on [KeyUpEvent].
bool consumeIfSuppressed(KeyEvent event) {
if (!_suppressed) return false;
if (!_keyMatcher(event.logicalKey)) return false;
if (event is KeyUpEvent) _suppressed = false;
if (event is KeyDownEvent && !consumeKeyDowns) {
// The in-flight KeyUp never arrived; this is a fresh press, not the
// suppressed one. Clear and let it through.
_suppressed = false;
return false;
}
return true;
}
}
@@ -149,50 +132,3 @@ class SelectKeyUpSuppressor {
static void clearSuppression() => _instance.clearSuppression();
static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event);
}
/// Global helper to suppress the next BACK key-up event.
///
/// Armed when a modal (dialog, sheet) closes while a back key is still held —
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — or when a
/// down-only back handler moves focus before the matching KeyUp is dispatched.
/// A matching KeyDown while armed clears the arming instead of consuming: it
/// can only mean the suppressed press's KeyUp was swallowed off-app, so the
/// fresh press must act normally.
class BackKeyUpSuppressor {
static final _instance = _KeyUpSuppressor((k) => k.isBackKey, consumeKeyDowns: false);
static void suppressBackUntilKeyUp() => _instance.suppress();
/// Clear any pending suppression. Call when opening a new modal
/// to ensure stale suppression from previous closes doesn't affect it.
static void clearSuppression() => _instance.clearSuppression();
static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event);
}
/// Tracks whether a back key is currently physically pressed.
///
/// Used by [BackKeySuppressorObserver] to detect when a route pop was
/// caused by a back key press (e.g. Flutter's built-in DismissAction,
/// DismissAction on KeyRepeat, or Android TV system back gesture) so it
/// can automatically suppress the stray KeyUp that follows.
class BackKeyPressTracker {
static bool _isBackKeyDown = false;
/// Whether a back key is currently held down.
///
/// Also checks [HardwareKeyboard.instance.logicalKeysPressed] as a
/// fallback in case our tracking drifted out of sync.
static bool get isBackKeyDown {
if (_isBackKeyDown) return true;
return HardwareKeyboard.instance.logicalKeysPressed.any((key) => key.isBackKey);
}
static bool handleKeyEvent(KeyEvent event) {
if (event.logicalKey.isBackKey) {
// KeyDown and KeyRepeat both mean the key is physically held.
_isBackKeyDown = event is! KeyUpEvent;
}
return false; // Never consume
}
}
+5 -18
View File
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../widgets/overlay_sheet.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
/// D-pad "move mode" reordering for a remote/keyboard-driven list of rows
/// inside a sheet or dialog.
@@ -41,7 +40,7 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
int? _originalIndex;
List<E>? _originalOrder;
bool _backKeyDownSeen = false;
final BackPressGate _backGate = BackPressGate();
/// The list being reordered. Mutated in place while moving and replaced
/// wholesale when a move is cancelled.
@@ -87,21 +86,9 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
KeyEventResult handleReorderKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
// Track back key down/up pairing. If focus was elsewhere during KeyDown
// (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray
// KeyUp that would incorrectly pop the dialog. Consume it instead.
if (key.isBackKey) {
if (event is KeyDownEvent) {
_backKeyDownSeen = true;
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
_backKeyDownSeen = false;
}
}
final backResult = handleBackKeyAction(event, () {
// The gate ignores a Back release whose press landed elsewhere (focus was
// on a sheet during KeyDown and returned here before KeyUp).
final backResult = _backGate.handle(event, () {
if (movingIndex != null) {
setState(() {
final originalOrder = _originalOrder;
+4 -1
View File
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../widgets/app_icon.dart';
import '../widgets/clickable_cursor.dart';
import 'back_press.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
@@ -98,6 +99,8 @@ class FocusableActionBarState extends State<FocusableActionBar> {
late List<FocusNode> _focusNodes;
late List<bool> _focusStates;
bool _hasAnyFocus = false;
// One gate for the row: every button shares [FocusableActionBar.onBack].
final _backGate = BackPressGate();
FocusNode? getFocusNode(int index) => index >= 0 && index < _focusNodes.length ? _focusNodes[index] : null;
@@ -320,7 +323,7 @@ class FocusableActionBarState extends State<FocusableActionBar> {
descendantsAreFocusable: false,
onKeyEvent: (node, event) {
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
final previousIndex = _previousEnabledIndex(index);
+4 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/scroll_utils.dart';
import 'back_press.dart';
import 'owned_focus_node_binding.dart';
import 'input_mode_tracker.dart';
import 'dpad_navigator.dart';
@@ -44,6 +45,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
final _focusNodeBinding = OwnedFocusNodeBinding();
bool _isFocused = false;
final _selectLongPress = DpadSelectLongPressController();
final _backGate = BackPressGate();
FocusNode? _boundExternalNode;
/// Override to return the widget's optional external focus node.
@@ -90,6 +92,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
setState(() => _isFocused = hasFocus);
if (!hasFocus) {
_selectLongPress.reset();
_backGate.reset();
}
// Same convention as FocusableTileStateMixin: a chip inside a
// scrollable strip (TabChipStrip, filter bars) reveals itself on
@@ -121,7 +124,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
final key = event.logicalKey;
if (callbacks.onBack != null) {
final backResult = handleBackKeyAction(event, callbacks.onBack!);
final backResult = _backGate.handle(event, callbacks.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
+11 -23
View File
@@ -7,6 +7,7 @@ import '../services/gamepad_service.dart';
import '../utils/platform_detector.dart';
import '../utils/text_input_diagnostics.dart';
import '../widgets/tv_virtual_keyboard.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
import 'owned_focus_node_binding.dart';
@@ -171,6 +172,7 @@ KeyEventResult _handleInputKey({
int? maxLines,
VoidCallback? onSelect,
VoidCallback? onBack,
required BackPressGate backGate,
VoidCallback? onNavigateLeft,
VoidCallback? onNavigateRight,
VoidCallback? onNavigateUp,
@@ -234,29 +236,11 @@ KeyEventResult _handleInputKey({
if (onBack != null && event.logicalKey.isBackKey) {
// On TV the native text-input path can swallow the matching KeyUp (the
// closing IME session eats it), so back fires on KeyDown — the same
// down-only shape as [handleBackKeyAction]'s Apple TV branch, coordinator
// mark included so a parallel back dispatch still dedupes. Elsewhere the
// shared handler's KeyUp semantics apply.
if (PlatformDetector.isTV()) {
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) return finish(KeyEventResult.handled, 'onBack');
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
// onBack may move focus (empty search field -> sidebar); the matching
// KeyUp is then delivered to the NEW focus chain, whose shared
// handlers act on KeyUp — a second back action. Arm the suppressor
// (after onBack, so a modal opened by it cannot clear the arming) so
// whichever chain receives the KeyUp swallows it. This cannot pin:
// the suppressor's hardware observer clears the armed state once the
// physical press ends, and if the IME swallows that KeyUp entirely,
// the next back KeyDown is treated as stale arming and passes
// through — see _KeyUpSuppressor.
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
return finish(KeyEventResult.handled, 'onBack');
}
final backResult = handleBackKeyAction(event, onBack);
// closing IME session eats it), so back fires on KeyDown. onBack may move
// focus (empty search field -> sidebar); the KeyUp is then delivered to the
// new focus chain, where no gate has seen its KeyDown and the navigator
// handler swallows it. Elsewhere the shared KeyUp phase applies.
final backResult = backGate.handle(event, onBack, phase: PlatformDetector.isTV() ? BackPhase.keyDown : null);
if (backResult != KeyEventResult.ignored) return finish(backResult, 'onBack');
}
@@ -740,6 +724,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
VoidCallback openKeyboard, {
required bool activateNativeTextInput,
required VoidCallback activateNativeTextInputCallback,
required BackPressGate backGate,
}) {
return _handleInputKey(
controller: controller,
@@ -760,6 +745,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
maxLines: maxLines,
onSelect: onSelect,
onBack: onBack,
backGate: backGate,
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
onNavigateUp: onNavigateUp,
@@ -796,6 +782,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode? _installedFocusNode;
FocusOnKeyEventCallback? _previousOnKeyEvent;
final BackPressGate _backGate = BackPressGate();
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
late final VoidCallback _focusListener = _handleFocusChanged;
final Object _nativeFocusToken = Object();
@@ -1286,6 +1273,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_openTvKeyboard,
activateNativeTextInput: activateNativeTextInput,
activateNativeTextInputCallback: _activateNativeTextInput,
backGate: _backGate,
);
}
+5 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/rendering.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
import 'dpad_select_long_press_controller.dart';
import 'focus_chrome.dart';
@@ -245,6 +246,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
Animation<double>? _scaleAnimation;
final _selectLongPress = DpadSelectLongPressController();
final _backGate = BackPressGate();
@override
void initState() {
@@ -307,9 +309,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
_isFocused = hasFocus;
});
// Reset long press state when focus is lost
// Reset press state when focus is lost
if (!hasFocus) {
_selectLongPress.reset();
_backGate.reset();
}
// Animate scale
@@ -448,7 +451,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
}
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return finish(backResult, 'onBack');
}
+27 -20
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -60,18 +58,10 @@ class InputModeTracker extends StatefulWidget {
/// Keyboard/D-pad on TV, pointer everywhere else.
static InputMode get _defaultMode => PlatformDetector.isTV() ? InputMode.keyboard : InputMode.pointer;
/// Whether system back must be blocked because the dpad key handler owns
/// back navigation: on Android in keyboard mode (TV/gamepad), letting the
/// system back through as well double-pops the route.
static bool shouldBlockSystemBack(BuildContext context) {
return Platform.isAndroid && isKeyboardMode(context);
}
/// Report input from a device that cannot point — gamepad, companion remote,
/// Siri Remote touch. Those services synthesize their key events through
/// [KeyEventSimulatorController], which dispatches straight down the focus
/// chain and never reaches [HardwareKeyboard], so they cannot be observed by
/// [eventRequestsFocusNavigation] and must announce themselves here.
/// Report input from a device that cannot point and does not produce key
/// events — a gamepad shoulder or a companion-remote tab command. Key-shaped
/// input from those devices goes through [KeyEventSimulatorController], which
/// runs the framework key pipeline and is observed here like any hardware key.
///
/// Calls `setState`, so never call this during build. A no-op when no tracker
/// is mounted.
@@ -89,6 +79,9 @@ class InputModeTracker extends StatefulWidget {
class _InputModeTrackerState extends State<InputModeTracker> {
InputMode _mode = InputModeTracker._defaultMode;
/// Whether the in-flight Back press found a primary focus on KeyDown.
bool _backDownHadFocus = false;
@override
void initState() {
super.initState();
@@ -109,18 +102,32 @@ class _InputModeTrackerState extends State<InputModeTracker> {
}
bool _handleKeyEvent(KeyEvent event) {
// Track back key press state for automatic suppression of stray KeyUp
// events after route pops (see BackKeySuppressorObserver).
BackKeyPressTracker.handleKeyEvent(event);
// Only a key that asks to navigate by focus starts a keyboard session.
// Activation and dismissal act on what is already focused, so promoting on
// them would arm focus chrome for a viewer who never asked to navigate.
if (eventRequestsFocusNavigation(event)) {
_setMode(InputMode.keyboard);
}
// Return false to let the event continue propagating
return false;
return _consumeBackReleaseWithoutFocus(event);
}
/// A Back KeyDown acted on a focus chain that has since been torn down, so
/// its KeyUp arrives with no primary focus at all. The focus system skips
/// dispatch and reports it unhandled; on Android the embedding then
/// redispatches it and `onBackPressed` → `popRoute` performs a second,
/// system-driven back. Claim it here in the hardware phase instead. A press
/// that already had no focus on KeyDown is left alone: neither event was
/// acted on, and the platform's own back is the correct outcome.
bool _consumeBackReleaseWithoutFocus(KeyEvent event) {
if (!event.logicalKey.isBackKey) return false;
if (event is KeyDownEvent) {
_backDownHadFocus = FocusManager.instance.primaryFocus != null;
return false;
}
if (event is! KeyUpEvent) return false;
final hadFocus = _backDownHadFocus;
_backDownHadFocus = false;
return hadFocus && FocusManager.instance.primaryFocus == null;
}
void _setMode(InputMode mode) {
+3 -131
View File
@@ -1,118 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Handles back key events by popping the current route.
///
/// Optionally pass a [result] to return to the previous route.
///
/// Use this as an `onKeyEvent` callback for Focus widgets that need
/// simple back navigation behavior:
///
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(context, event),
/// child: ...
/// )
/// ```
///
/// With a result value:
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(
/// context,
/// event,
/// result: _hasChanges,
/// ),
/// child: ...
/// )
/// ```
class BackKeyCoordinator {
static bool _handledThisFrame = false;
static bool _clearScheduled = false;
static int _clearGeneration = 0;
static void markHandled() {
_handledThisFrame = true;
if (_clearScheduled) return;
_clearScheduled = true;
final clearGeneration = _clearGeneration;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (clearGeneration != _clearGeneration) return;
_handledThisFrame = false;
_clearScheduled = false;
});
// addPostFrameCallback does not request a frame. Ensure the one-shot
// marker cannot leak when handling Back does not otherwise schedule one.
WidgetsBinding.instance.scheduleFrame();
}
static void clear() {
_handledThisFrame = false;
_clearScheduled = false;
// Invalidate a pending callback so it cannot clear a newer marker.
_clearGeneration++;
}
static bool consumeIfHandled() {
if (!_handledThisFrame) return false;
clear();
return true;
}
}
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
// Check if this BACK event should be suppressed (e.g., after modal closed)
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack
// on KeyDown only; consume KeyUp silently. Some engine paths report Menu as
// a non-keyboard device, but the same down-only handling is still required.
// No suppressor arming is needed here: onBack typically calls
// Navigator.pop, swapping the focus tree before the matching KeyUp is
// dispatched — but the orphaned KeyUp lands on the new chain, where this
// same branch consumes it silently.
if (PlatformDetector.isAppleTV()) {
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
}
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
BackKeyCoordinator.markHandled();
onBack();
return KeyEventResult.handled;
}
if (event is KeyDownEvent || event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
// Let system handle back if there's nothing to pop (e.g. root screen)
if (!Navigator.canPop(context)) {
return KeyEventResult.ignored;
}
// Don't handle back when a dialog/overlay is on top of our route —
// the overlay handles its own dismissal via DismissAction.
if (ModalRoute.of(context)?.isCurrent != true) {
return KeyEventResult.ignored;
}
// 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)
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
// Back key handling lives in back_press.dart (BackPressGate for owners that
// consume Back before the route) and navigator_back_handler.dart (the
// per-navigator fallback that turns Back into Navigator.maybePop).
/// Consumes all select-key events (down, repeat, up) so they don't reach
/// platform-level handlers; fires [onActivate] on the initial KeyDown only.
@@ -234,24 +127,3 @@ FocusOnKeyEventCallback dpadKeyHandler({
return KeyEventResult.ignored;
};
}
/// Navigator observer that automatically suppresses stray back KeyUp events
/// after any route pop caused by a back key press.
///
/// This catches pops triggered by Flutter's built-in DismissAction (which fires
/// on KeyDown for dialogs) and Android TV system back gestures, preventing the
/// orphaned KeyUp from propagating to the underlying screen's back handler.
class BackKeySuppressorObserver extends NavigatorObserver {
@override
void didPop(Route route, Route? previousRoute) {
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
// so the suppressor isn't needed. (The atomic engine fix delivers
// KeyDown+KeyUp in a single recognizer Began callback, so didPop fires
// squarely inside the window where BackKeyPressTracker.isBackKeyDown is
// true.)
if (PlatformDetector.isAppleTV()) return;
if (BackKeyPressTracker.isBackKeyDown) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'back_press.dart';
import 'dpad_navigator.dart';
/// Turns a Back key press that no focused owner consumed into
/// `Navigator.maybePop` on [navigatorKey], so key back and system back both end
/// in the route's `PopScope`.
///
/// Wrap each [Navigator] (root and profile session) once. The wrapper sits in
/// the focus chain above every route of that navigator, so it sees only the
/// presses that bubbled out of the focused subtree. It decides on KeyDown, from
/// the route's synchronous [ModalRoute.popDisposition], whether this navigator
/// owns the press:
///
/// * `pop` / `doNotPop` — a route below or a `PopScope(canPop: false)`: the
/// press is consumed and `maybePop` runs on the configured [BackPhase].
/// * `bubble` — nothing to pop and no `PopScope`: the press is declined, both
/// KeyDown and KeyUp bubble on. On Android an unhandled Back becomes the
/// platform's own `onBackPressed` → `popRoute` → `SystemNavigator.pop`, which
/// is the correct "leave the app" behavior at the true root.
///
/// A KeyUp or KeyRepeat that arrives with no armed press — the press acted on
/// another focus chain before focus moved here — is swallowed. Letting it reach
/// the platform unhandled would synthesize a second, system-driven back.
class NavigatorBackHandler extends StatefulWidget {
const NavigatorBackHandler({super.key, required this.navigatorKey, required this.child});
final GlobalKey<NavigatorState> navigatorKey;
final Widget child;
@override
State<NavigatorBackHandler> createState() => _NavigatorBackHandlerState();
}
/// What this navigator does with the Back press whose KeyDown just arrived.
enum _Claim {
/// Consume the press and run `maybePop` on the configured phase.
pop,
/// Consume the press and do nothing: the focused route is not the current
/// one. A route was just pushed and the new route's autofocus has not been
/// applied yet (FocusManager applies it in a microtask), so the old chain
/// still receives keys; `maybePop` would pop the route that was just pushed.
swallow,
/// Leave both events to the platform: nothing to pop and no `PopScope`.
decline,
}
class _NavigatorBackHandlerState extends State<NavigatorBackHandler> {
final BackPressGate _gate = BackPressGate();
bool _declined = false;
/// The route of this navigator that contains the primary focus, walking out
/// of nested navigators. Null when the focus is not inside any route of this
/// navigator (e.g. chrome mounted beside the navigator).
ModalRoute<Object?>? _routeForPrimaryFocus() {
final navigator = widget.navigatorKey.currentState;
if (navigator == null) return null;
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) return null;
var route = ModalRoute.of(context);
while (route != null && !identical(route.navigator, navigator)) {
final navigatorContext = route.navigator?.context;
if (navigatorContext == null) return null;
route = ModalRoute.of(navigatorContext);
}
return route;
}
_Claim _claimForPrimaryFocus() {
final route = _routeForPrimaryFocus();
if (route == null) return _Claim.decline;
if (!route.isCurrent) return _Claim.swallow;
return route.popDisposition == RoutePopDisposition.bubble ? _Claim.decline : _Claim.pop;
}
void _pop() {
unawaited(widget.navigatorKey.currentState?.maybePop());
}
static void _noop() {}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
if (event is KeyDownEvent) {
switch (_claimForPrimaryFocus()) {
case _Claim.decline:
_declined = true;
_gate.reset();
return KeyEventResult.ignored;
case _Claim.swallow:
_declined = false;
return _gate.handle(event, _noop, phase: BackPhase.keyDown);
case _Claim.pop:
_declined = false;
return _gate.handle(event, _pop);
}
}
if (_declined) {
if (event is KeyUpEvent) _declined = false;
return KeyEventResult.ignored;
}
// An armed press acts (or, already acted on KeyDown, is finished); an
// unarmed KeyUp/KeyRepeat acted on another focus chain and is swallowed.
final result = _gate.handle(event, _pop);
return result == KeyEventResult.ignored ? KeyEventResult.handled : result;
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
debugLabel: 'NavigatorBackHandler',
onKeyEvent: _handleKeyEvent,
child: widget.child,
);
}
}
+7 -7
View File
@@ -83,7 +83,7 @@ import 'i18n/app_locale_utils.dart';
import 'i18n/strings.g.dart';
import 'widgets/app_icon.dart';
import 'focus/input_mode_tracker.dart';
import 'focus/key_event_utils.dart';
import 'focus/navigator_back_handler.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'utils/navigation_transitions.dart';
import 'utils/log_redaction_manager.dart';
@@ -1802,23 +1802,23 @@ class _AppShell extends StatelessWidget {
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorKey: rootNavigatorKey,
navigatorObservers: [BackKeySuppressorObserver()],
home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome),
// Siri Remote select + gamepad A report as
// LogicalKeyboardKey.{select,gameButtonA} which aren't
// in Flutter's default shortcut set — Material-level
// widgets (menu items, showModalBottomSheet actions)
// ignore them. Map both to ActivateIntent so tapping
// select on tvOS activates the focused widget.
// select on tvOS activates the focused widget. Back keys
// need no DismissIntent binding: NavigatorBackHandler
// turns them into maybePop before they reach here.
shortcuts: <ShortcutActivator, Intent>{
...WidgetsApp.defaultShortcuts,
const SingleActivator(LogicalKeyboardKey.select): const ActivateIntent(),
const SingleActivator(LogicalKeyboardKey.gameButtonA): const ActivateIntent(),
const SingleActivator(LogicalKeyboardKey.goBack): const DismissIntent(),
const SingleActivator(LogicalKeyboardKey.browserBack): const DismissIntent(),
const SingleActivator(LogicalKeyboardKey.gameButtonB): const DismissIntent(),
},
builder: (context, child) => rootShell(child),
builder: (context, child) => rootShell(
NavigatorBackHandler(navigatorKey: rootNavigatorKey, child: child ?? const SizedBox.shrink()),
),
),
),
);
+13 -10
View File
@@ -6,7 +6,7 @@ import 'package:provider/provider.dart';
import '../connection/connection_registry.dart';
import '../focus/covered_route_focus_boundary.dart';
import '../focus/key_event_utils.dart';
import '../focus/navigator_back_handler.dart';
import '../media/ids.dart';
import '../media/media_server_client.dart';
import '../profiles/active_profile_provider.dart';
@@ -358,15 +358,18 @@ class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> {
child: SettingsShortcut(
navigatorKey: _navigatorKey,
settingsRoutes: _settingsRouteTracker,
child: Stack(
children: [
Navigator(
key: _navigatorKey,
observers: [_routeObserver, _musicRouteObserver, _settingsRouteTracker, BackKeySuppressorObserver()],
onGenerateRoute: _onGenerateRoute,
),
const Positioned.fill(child: MusicMiniPlayerOverlay()),
],
child: NavigatorBackHandler(
navigatorKey: _navigatorKey,
child: Stack(
children: [
Navigator(
key: _navigatorKey,
observers: [_routeObserver, _musicRouteObserver, _settingsRouteTracker],
onGenerateRoute: _onGenerateRoute,
),
const Positioned.fill(child: MusicMiniPlayerOverlay()),
],
),
),
),
),
+46 -55
View File
@@ -22,7 +22,6 @@ import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/key_event_utils.dart';
import '../media/media_backend.dart';
import '../media/media_browser_dialect.dart';
import '../navigation/profile_session_screen.dart';
@@ -260,65 +259,57 @@ class _AuthScreenState extends State<AuthScreen> {
// Use two-column layout on desktop, single column on mobile
final isDesktop = MediaQuery.sizeOf(context).width > 700;
return Focus(
canRequestFocus: false,
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
child: Scaffold(
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: isDesktop
? Row(
crossAxisAlignment: .center,
children: [
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(width: 48),
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [_buildAuthBody()],
),
return Scaffold(
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: isDesktop
? Row(
crossAxisAlignment: .center,
children: [
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(width: 48),
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(mainAxisSize: .min, crossAxisAlignment: .stretch, children: [_buildAuthBody()]),
),
),
],
)
: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
_buildAuthBody(),
],
),
],
)
: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
_buildAuthBody(),
],
),
),
),
),
),
);
+192 -205
View File
@@ -12,8 +12,6 @@ import '../focus/focusable_action_bar.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../focus/locked_hub_controller.dart';
import '../i18n/app_locale_utils.dart';
import '../i18n/strings.g.dart';
@@ -1278,11 +1276,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
);
}
void _handleSystemBack() {
if (BackKeyCoordinator.consumeIfHandled()) return;
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final item = _item;
@@ -1298,221 +1291,215 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
final isMobile = PlatformDetector.isMobile(context);
final viewInsets = MediaQuery.paddingOf(context);
final blockSystemBack = PlatformDetector.isTV() || InputModeTracker.shouldBlockSystemBack(context);
// Match the established detail-screen back policy: TV/keyboard back is
// owned by the focus tree, while native mobile back and iOS swipe-back
// remain route-driven. The overlay host always gets first refusal.
// The host owns route back for key and system back alike: a back with a
// sheet open closes it; otherwise the route pops. iOS keeps canPop for the
// interactive swipe. Nothing here handles Back keys — unconsumed presses
// reach the navigator, which runs the host's PopScope.
return OverlaySheetHost(
canPop: !blockSystemBack,
onSystemBack: _handleSystemBack,
canPop: PlatformDetector.isHandheldIOS(context),
onBack: () => Navigator.pop(context),
child: Builder(
builder: (hostContext) => Focus(
onKeyEvent: (_, event) => handleBackKeyNavigation(hostContext, event),
child: Scaffold(
body: Stack(
children: [
SingleChildScrollView(
key: const Key('catalog_detail_scroll'),
controller: _scrollController,
// The backdrop lives inside the scrollable so it moves with
// the content (it extends under the status bar, so the safe
// areas are baked into the content padding instead of a
// SafeArea around the scroll view).
child: Stack(
children: [
if (backdropUrl != null)
Positioned(
top: 0,
left: 0,
right: 0,
height: 320,
child: ShaderMask(
shaderCallback: (rect) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
stops: const [0.3, 1.0],
).createShader(rect),
blendMode: BlendMode.dstIn,
child: OptimizedMediaImage.thumb(
imagePath: backdropUrl,
width: double.infinity,
height: 320,
fit: BoxFit.cover,
fallbackIcon: null,
),
builder: (hostContext) => Scaffold(
body: Stack(
children: [
SingleChildScrollView(
key: const Key('catalog_detail_scroll'),
controller: _scrollController,
// The backdrop lives inside the scrollable so it moves with
// the content (it extends under the status bar, so the safe
// areas are baked into the content padding instead of a
// SafeArea around the scroll view).
child: Stack(
children: [
if (backdropUrl != null)
Positioned(
top: 0,
left: 0,
right: 0,
height: 320,
child: ShaderMask(
shaderCallback: (rect) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
stops: const [0.3, 1.0],
).createShader(rect),
blendMode: BlendMode.dstIn,
child: OptimizedMediaImage.thumb(
imagePath: backdropUrl,
width: double.infinity,
height: 320,
fit: BoxFit.cover,
fallbackIcon: null,
),
),
Padding(
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
child: Column(
crossAxisAlignment: .start,
children: [
Row(
crossAxisAlignment: .start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: OptimizedMediaImage.poster(imagePath: posterUrl, width: 140, height: 210),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
if (item.tagline?.trim() case final tagline? when tagline.isNotEmpty) ...[
Text(
tagline,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.75),
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 6),
],
),
Padding(
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
child: Column(
crossAxisAlignment: .start,
children: [
Row(
crossAxisAlignment: .start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: OptimizedMediaImage.poster(imagePath: posterUrl, width: 140, height: 210),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
if (item.tagline?.trim() case final tagline? when tagline.isNotEmpty) ...[
Text(
item.title,
style: theme.textTheme.headlineMedium,
maxLines: 3,
overflow: .ellipsis,
tagline,
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.75),
fontStyle: FontStyle.italic,
),
),
if (_metaLine.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_metaLine,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(height: 6),
],
Text(
item.title,
style: theme.textTheme.headlineMedium,
maxLines: 3,
overflow: .ellipsis,
),
if (_metaLine.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_metaLine,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
],
if (item.genres?.isNotEmpty ?? false) ...[
const SizedBox(height: 8),
Text(
item.genres!.join(' • '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
if (item.genres?.isNotEmpty ?? false) ...[
const SizedBox(height: 8),
Text(
item.genres!.join(' • '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
],
const SizedBox(height: 16),
if (_hasActions)
FocusableActionBar(
key: _actionBarKey,
onNavigateDown: _focusSectionBelowActions,
actions: [
if (_watchlistSource != null)
FocusableAction(
// Stable identities so the focused binding survives the
// list-shape changes of async enrichment (watchlist/request
// sources and trailer URL arrive at different times).
debugLabel: 'catalog_watchlist',
icon: onWatchlist ?? false
? Symbols.bookmark_added_rounded
: Symbols.bookmark_add_rounded,
tooltip: onWatchlist ?? false
? t.explore.removeFromWatchlist
: t.explore.addToWatchlist,
onPressed: () => unawaited(_toggleWatchlist()),
),
if (_requestSource case final SeerrCatalogSource seerr when tmdbId != null)
FocusableAction(
debugLabel: 'catalog_request',
icon: Symbols.download_rounded,
tooltip: t.seerr.request,
onPressed: () => unawaited(
showSeerrRequestSheet(
hostContext,
source: seerr,
kind: item.kind,
tmdbId: tmdbId,
title: item.title,
),
),
],
const SizedBox(height: 16),
if (_hasActions)
FocusableActionBar(
key: _actionBarKey,
onNavigateDown: _focusSectionBelowActions,
actions: [
if (_watchlistSource != null)
FocusableAction(
// Stable identities so the focused binding survives the
// list-shape changes of async enrichment (watchlist/request
// sources and trailer URL arrive at different times).
debugLabel: 'catalog_watchlist',
icon: onWatchlist ?? false
? Symbols.bookmark_added_rounded
: Symbols.bookmark_add_rounded,
tooltip: onWatchlist ?? false
? t.explore.removeFromWatchlist
: t.explore.addToWatchlist,
onPressed: () => unawaited(_toggleWatchlist()),
),
if (_requestSource case final SeerrCatalogSource seerr when tmdbId != null)
FocusableAction(
debugLabel: 'catalog_request',
icon: Symbols.download_rounded,
tooltip: t.seerr.request,
onPressed: () => unawaited(
showSeerrRequestSheet(
hostContext,
source: seerr,
kind: item.kind,
tmdbId: tmdbId,
title: item.title,
),
),
if (item.trailerUrl?.trim() case final trailer? when trailer.isNotEmpty)
FocusableAction(
debugLabel: 'catalog_trailer',
icon: Symbols.play_circle_rounded,
tooltip: t.explore.detail.watchTrailer,
onPressed: () => unawaited(_openExternalUrl(trailer)),
),
],
),
],
),
),
if (item.trailerUrl?.trim() case final trailer? when trailer.isNotEmpty)
FocusableAction(
debugLabel: 'catalog_trailer',
icon: Symbols.play_circle_rounded,
tooltip: t.explore.detail.watchTrailer,
onPressed: () => unawaited(_openExternalUrl(trailer)),
),
],
),
],
),
],
),
if (_buildStatsChips() case final Widget chips) ...[const SizedBox(height: 20), chips],
if (item.overview?.trim() case final overview? when overview.isNotEmpty) ...[
const SizedBox(height: 24),
CollapsibleText(
text: overview,
maxLines: isMobile ? 6 : 4,
style: theme.textTheme.bodyLarge,
focusNode: _overviewFocusNode,
skipTraversal: false,
onNavigateUp: _hasActions ? _requestActionBarFocus : null,
onNavigateDown: _focusSectionBelowOverview,
),
],
if (item.background?.trim() case final background? when background.isNotEmpty) ...[
const SizedBox(height: 24),
_buildBackgroundSection(theme, background, isMobile: isMobile),
],
if (_buildFactsSection(theme) case final Widget facts) ...[
const SizedBox(height: 24),
facts,
],
if (_buildRecommendersSection(theme) case final Widget recommenders) ...[
const SizedBox(height: 24),
recommenders,
],
if (_buildRatingsSection(theme) case final Widget ratings) ...[
const SizedBox(height: 24),
ratings,
],
if (_buildScheduleSection(theme) case final Widget schedule) ...[
const SizedBox(height: 24),
schedule,
],
if (_buildCrewSection(theme) case final Widget crew) ...[const SizedBox(height: 24), crew],
if (_buildTagsSection(theme) case final Widget tags) ...[const SizedBox(height: 24), tags],
if (_streamingLinks.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLinksSection(theme, t.explore.detail.watchOn, _streamingLinks, 0),
],
if (_otherLinks.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLinksSection(theme, t.explore.detail.links, _otherLinks, _streamingLinks.length),
],
),
if (_buildStatsChips() case final Widget chips) ...[const SizedBox(height: 20), chips],
if (item.overview?.trim() case final overview? when overview.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLibrarySection(theme),
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
const SizedBox(height: 28),
_buildCastSection(theme, cast),
],
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
const SizedBox(height: 20),
_buildRelatedSection(related),
],
CollapsibleText(
text: overview,
maxLines: isMobile ? 6 : 4,
style: theme.textTheme.bodyLarge,
focusNode: _overviewFocusNode,
skipTraversal: false,
onNavigateUp: _hasActions ? _requestActionBarFocus : null,
onNavigateDown: _focusSectionBelowOverview,
),
],
),
if (item.background?.trim() case final background? when background.isNotEmpty) ...[
const SizedBox(height: 24),
_buildBackgroundSection(theme, background, isMobile: isMobile),
],
if (_buildFactsSection(theme) case final Widget facts) ...[const SizedBox(height: 24), facts],
if (_buildRecommendersSection(theme) case final Widget recommenders) ...[
const SizedBox(height: 24),
recommenders,
],
if (_buildRatingsSection(theme) case final Widget ratings) ...[
const SizedBox(height: 24),
ratings,
],
if (_buildScheduleSection(theme) case final Widget schedule) ...[
const SizedBox(height: 24),
schedule,
],
if (_buildCrewSection(theme) case final Widget crew) ...[const SizedBox(height: 24), crew],
if (_buildTagsSection(theme) case final Widget tags) ...[const SizedBox(height: 24), tags],
if (_streamingLinks.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLinksSection(theme, t.explore.detail.watchOn, _streamingLinks, 0),
],
if (_otherLinks.isNotEmpty) ...[
const SizedBox(height: 24),
_buildLinksSection(theme, t.explore.detail.links, _otherLinks, _streamingLinks.length),
],
const SizedBox(height: 24),
_buildLibrarySection(theme),
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
const SizedBox(height: 28),
_buildCastSection(theme, cast),
],
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
const SizedBox(height: 20),
_buildRelatedSection(related),
],
],
),
],
),
),
],
),
Positioned(
top: 0,
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
AppBarBackButton(style: BackButtonStyle.circular, focusNode: _backButtonFocusNode),
context: hostContext,
)!,
),
],
),
),
Positioned(
top: 0,
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
AppBarBackButton(style: BackButtonStyle.circular, focusNode: _backButtonFocusNode),
context: hostContext,
)!,
),
],
),
),
),
+3 -1
View File
@@ -13,6 +13,7 @@ import '../focus/hub_vertical_navigation.dart';
import '../focus/locked_hub_controller.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../focus/back_press.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
@@ -78,6 +79,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
with Refreshable, FullRefreshable, TabVisibilityAware, FocusableTab, WidgetsBindingObserver {
static const Duration _heroAutoScrollDuration = Duration(seconds: 8);
static const Duration _indicatorUpdateInterval = Duration(milliseconds: 200);
final _backGate = BackPressGate();
/// Data + refresh policy live in [DiscoverProvider]; this state keeps only
/// UI concerns (hero carousel, focus, spotlight). The proxy getters keep
@@ -412,7 +414,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Handle key events for the hero section.
KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) {
final backResult = handleBackKeyAction(event, _navigateToSidebar);
final backResult = _backGate.handle(event, _navigateToSidebar);
if (backResult != KeyEventResult.ignored) return backResult;
return dpadKeyHandler(
+32 -35
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../mixins/grid_focus_node_mixin.dart';
@@ -32,9 +31,6 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
// App bar focus state
bool isAppBarFocused = false;
// Flag to prevent PopScope from exiting when BACK was handled by a key handler
bool backHandledByKeyEvent = false;
/// Called when items are available and we want to check if focus should be set
bool get hasItems;
@@ -58,14 +54,21 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
/// Handle BACK key from content - navigate to app bar and set flag to prevent PopScope exit
void handleBackFromContent() {
if (getAppBarActions().isEmpty) {
if (mounted) Navigator.pop(context);
return;
/// Whether the mounted app bar has actions. Derived from the bar itself so it
/// is safe in callbacks: [getAppBarActions] may read providers with
/// `context.select`, which is only legal during build.
bool get hasAppBarActions => actionBarKey.currentState?.widget.actions.isNotEmpty ?? false;
/// Back handler for focused content (grid cards, track rows): the content
/// widget's gate consumes the press and moves focus to the app bar, or pops
/// the route when there is no app bar to move to. Decided at press time, so a
/// bar mounted after the content was built is still honored.
void onBackFromContent() {
if (hasAppBarActions) {
navigateToAppBar();
} else if (mounted) {
Navigator.pop(context);
}
backHandledByKeyEvent = true;
navigateToAppBar();
}
/// Navigate focus from app bar down to the grid
@@ -84,8 +87,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'detail_grid_item');
/// Wrap [slivers] in the standard detail-screen scaffold — an overlay-sheet
/// host that defers route back to [handleBackNavigation], plus a Scaffold
/// with a CustomScrollView bound as the primary scroll view. Callers build
/// host whose route back (key or system) runs [handleBackNavigation], plus a
/// Scaffold with a CustomScrollView bound as the primary scroll view. Callers build
/// the slivers themselves (typically
/// `[appBar, ...header, ...buildStateSlivers(), grid]`); a trailing
/// [SliverSystemBottomInset] is appended so the last row clears the system
@@ -110,11 +113,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
controller: scrollController,
child: OverlaySheetHost(
canPop: PlatformDetector.isHandheldIOS(context),
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (handleBackNavigation() && mounted) {
Navigator.pop(context);
}
onBack: () {
if (handleBackNavigation() && mounted) Navigator.pop(context);
},
child: Scaffold(body: body),
),
@@ -122,30 +122,26 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
);
}
/// Handle back navigation for PopScope. Returns true if should pop.
/// The route's back policy, for key and system back alike: pop once the app
/// bar is focused (or there is none); otherwise focus the app bar first.
/// Returns true if the route should pop.
bool handleBackNavigation() {
// If BACK was already handled by a key event, don't pop
if (backHandledByKeyEvent) {
backHandledByKeyEvent = false;
return false;
}
if (isAppBarFocused || getAppBarActions().isEmpty) {
return true;
} else {
// Focus app bar first
navigateToAppBar();
return false;
}
if (isAppBarFocused || !hasAppBarActions) return true;
navigateToAppBar();
return false;
}
/// Build focusable app bar action widgets
/// Build focusable app bar action widgets. The bar handles no Back key of its
/// own: a press with the bar focused bubbles to the route, whose PopScope
/// runs [handleBackNavigation] (true once the bar is focused) and pops.
List<Widget> buildFocusableAppBarActions() {
return [
FocusableActionBar(
key: actionBarKey,
onNavigateDown: navigateToGrid,
onBack: () => Navigator.pop(context),
onFocusChange: (hasFocus) {
if (hasFocus && !isAppBarFocused) setState(() => isAppBarFocused = true);
},
actions: getAppBarActions(),
),
];
@@ -215,6 +211,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
CardShape? shape,
int indexOffset = 0,
}) {
final onBack = onBackFromContent;
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
@@ -248,7 +245,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
// sections fall through to traversal, which enters the previous
// section's grid.
onNavigateUp: position.isFirstRow && indexOffset == 0 ? navigateToAppBar : null,
onBack: handleBackFromContent,
onBack: onBack,
onFocusChange: (hasFocus) => trackGridItemFocus(globalIndex, hasFocus),
);
}
+8 -8
View File
@@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/overlay_sheet.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart';
import '../mixins/paginated_item_loader.dart';
import 'libraries/sort_bottom_sheet.dart';
@@ -503,12 +502,12 @@ class _HubDetailScreenState extends State<HubDetailScreen>
child: IosStatusBarTapScrollToTop(
controller: scrollController,
child: OverlaySheetHost(
// Host owns sheet + system back: a back with a sheet open closes it;
// otherwise focus the app bar first, then pop (handleBackNavigation).
// canPop preserves the iOS interactive swipe-back.
// Host owns sheet + route back for key and system back alike: a back
// with a sheet open closes it; otherwise focus the app bar first, then
// pop (handleBackNavigation). canPop preserves the iOS interactive
// swipe-back.
canPop: PlatformDetector.isHandheldIOS(context),
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
onBack: () {
if (handleBackNavigation() && mounted) Navigator.pop(context);
},
child: Scaffold(
@@ -538,6 +537,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
final onBack = onBackFromContent;
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
@@ -582,7 +582,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
? _continuationRetryFocusNode.requestFocus
: null,
onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
onBack: onBack,
onFocusChange: (hasFocus) => _handleGridItemFocusChange(
index,
hasFocus,
@@ -602,7 +602,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
retryFocusNode: _continuationRetryFocusNode,
errorContext: widget.hub.title,
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
onBack: handleBackFromContent,
onBack: onBackFromContent,
),
const SliverSystemBottomInset(),
],
+3 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../focus/key_event_utils.dart';
import '../../focus/back_press.dart';
import '../../i18n/strings.g.dart';
import '../../media/library_first_character.dart';
import '../../widgets/clickable_cursor.dart';
@@ -45,6 +46,7 @@ class AlphaJumpBar extends StatefulWidget {
class _AlphaJumpBarState extends State<AlphaJumpBar> {
late AlphaJumpHelper _helper;
final _backGate = BackPressGate();
/// Subset of letters actually rendered, filtered by available height.
List<String> _displayed = const [];
@@ -161,7 +163,7 @@ class _AlphaJumpBarState extends State<AlphaJumpBar> {
});
if (selectResult != KeyEventResult.ignored) return selectResult;
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
+9 -10
View File
@@ -12,7 +12,7 @@ import '../../../focus/dpad_navigator.dart';
import '../../../focus/dpad_select_long_press_controller.dart';
import '../../../focus/focus_theme.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../focus/key_event_utils.dart';
import '../../../focus/back_press.dart';
import '../../../i18n/app_locale_utils.dart';
import '../../../i18n/strings.g.dart';
import '../../../mixins/mounted_set_state_mixin.dart';
@@ -117,6 +117,7 @@ class GuideTabState extends State<GuideTab>
static const _sourceHeaderRowHeight = 40.0;
static const _timeHeaderHeight = 40.0;
static const _minutesPerSlot = 30;
final _backGate = BackPressGate();
/// Minimum time away (backgrounded or on another section) before the
/// viewport is realigned to the live line on return.
@@ -818,21 +819,19 @@ class GuideTabState extends State<GuideTab>
return KeyEventResult.handled;
}
// Back key
// Back key: from the grid, move focus up to the time navigation row;
// from there, hand the press to the owner via onBack.
if (key.isBackKey) {
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
if (_focusZone == _GuideZone.grid) {
if (event is KeyUpEvent) {
return _backGate.handle(event, () {
if (_focusZone == _GuideZone.grid) {
_updateFocus(() {
_focusZone = _GuideZone.timeNav;
_timeNavIndex = 1;
});
} else {
widget.onBack?.call();
}
return KeyEventResult.handled;
}
return handleBackKeyAction(event, () => widget.onBack?.call());
});
}
if (PlatformDetector.isTV()) {
+27 -74
View File
@@ -59,8 +59,8 @@ import '../utils/desktop_window_padding.dart';
import '../widgets/music/mini_player.dart';
import '../widgets/mobile_navigation_rail.dart';
import '../widgets/side_navigation_rail.dart';
import '../focus/back_press.dart';
import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart';
import 'discover_screen.dart';
import 'explore_screen.dart';
import 'libraries/library_quick_picker_sheet.dart';
@@ -1472,14 +1472,17 @@ class _MainScreenState extends State<MainScreen>
_setTvosMenuPassthrough(_shouldPassTvosMenuToSystem);
}
/// Suppress stray back events after a child route pops.
/// On Android TV the platform popRoute can arrive before the key events,
/// so BackKeySuppressorObserver misses them and they leak into _handleBackKey.
bool _suppressBackAfterPop = false;
/// Owner gate for the one Back the content region consumes before the route:
/// moving focus from content to the sidebar.
final BackPressGate _backGate = BackPressGate();
KeyEventResult _handleMainBack() {
/// The route's back policy, reached through the [OverlaySheetHost] PopScope
/// for both key back (via NavigatorBackHandler) and system back: another tab
/// goes home, home on Apple TV only logs, elsewhere home arms press-back-twice
/// to exit.
void _handleMainBack() {
final tabs = _getVisibleTabs(_isOffline);
if (tabs.isEmpty) return KeyEventResult.handled;
if (tabs.isEmpty) return;
final homeTab = tabs.first.id;
if (_currentTab != homeTab) {
@@ -1488,7 +1491,7 @@ class _MainScreenState extends State<MainScreen>
// already has focus, so no post-frame deferral is needed.
_sideNavKey.currentState?.focusHomeItem();
_lastBackPressAt = null;
return KeyEventResult.handled;
return;
}
// The tvOS engine normally passes root Menu presses through to UIKit. If a
@@ -1502,7 +1505,7 @@ class _MainScreenState extends State<MainScreen>
'passthrough=$_shouldPassTvosMenuToSystem picker=$_isShowingProfileSelection '
'focus=${FocusManager.instance.primaryFocus?.debugLabel}',
);
return KeyEventResult.handled;
return;
}
final now = DateTime.now();
@@ -1510,64 +1513,26 @@ class _MainScreenState extends State<MainScreen>
if (lastBackPressAt != null && now.difference(lastBackPressAt) < _backExitWindow) {
_lastBackPressAt = null;
unawaited(AppExitService.requestExit());
return KeyEventResult.handled;
return;
}
_lastBackPressAt = now;
showMainSnackBar(t.common.pressBackAgainToExit, duration: _backExitWindow);
return KeyEventResult.handled;
}
KeyEventResult _handleMainBackKeyAction(KeyEvent event) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// AppleTV: KeyDown does the work, KeyUp is consumed silently. See the
// matching comment in handleBackKeyAction for why the suppressor pattern
// doesn't fit here.
if (PlatformDetector.isAppleTV() && event is KeyDownEvent) {
final result = _handleMainBack();
if (result == KeyEventResult.handled) {
BackKeyCoordinator.markHandled();
}
return result;
}
if (PlatformDetector.isAppleTV() && event is KeyUpEvent) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
final result = _handleMainBack();
if (result == KeyEventResult.handled) {
BackKeyCoordinator.markHandled();
}
return result;
}
if (event is KeyDownEvent || event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Content focused → Back moves focus to the sidebar (owner behavior, gated
/// per press). Sidebar focused → the press is left alone so it bubbles to
/// NavigatorBackHandler and lands in this route's PopScope ([_handleMainBack]).
KeyEventResult _handleBackKey(KeyEvent event) {
if (ModalRoute.of(context)?.isCurrent != true) {
return KeyEventResult.ignored;
}
if (_suppressBackAfterPop && event.logicalKey.isBackKey) {
if (event is KeyUpEvent) _suppressBackAfterPop = false;
return KeyEventResult.handled;
}
if (!_isSidebarFocused) {
// Content focused → move to sidebar
return handleBackKeyAction(event, _focusSidebar);
return _backGate.handle(event, _focusSidebar);
}
return _handleMainBackKeyAction(event);
return KeyEventResult.ignored;
}
/// F11 toggles OS fullscreen from anywhere in the main UI. The in-player
@@ -1600,7 +1565,6 @@ class _MainScreenState extends State<MainScreen>
if (!shouldHandle) return KeyEventResult.ignored;
if (event is KeyUpEvent) {
BackKeyCoordinator.markHandled();
unawaited(FullscreenStateManager().exitFullscreenIfActive());
}
return event is KeyDownEvent || event is KeyRepeatEvent || event is KeyUpEvent
@@ -1662,15 +1626,6 @@ class _MainScreenState extends State<MainScreen>
@override
void didPopNext() {
// Suppress stray back key events from the pop that just returned us here
_suppressBackAfterPop = true;
// Auto-clear after 2 frames in case no back event arrives
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_suppressBackAfterPop = false;
});
});
// Called when returning to this route from a child route (e.g., from video player)
_updateTvosMenuPassthrough();
_miniPlayerInsets?.setNavBarSuspended(false);
@@ -2009,12 +1964,12 @@ class _MainScreenState extends State<MainScreen>
return OverlaySheetHost(
onOpenChanged: _handleOverlaySheetOpenChanged,
// canPop:false blocks the system route-pop (matching the old inert
// PopScope). The dpad/key back chain (content → top tabs → sidebar →
// home) is owned entirely by the key path below; there is NO
// onSystemBack because a pure popRoute must not short-circuit that
// chain to home. The host still closes an open sheet on system back.
// The route's single back policy: a Back key that neither the
// content region (→ sidebar, in _handleBackKey) nor a focused child
// consumed bubbles to NavigatorBackHandler and, like a system back,
// arrives here. The host closes an open sheet first.
canPop: false,
onBack: _handleMainBack,
child: Focus(
onKeyEvent: (node, event) {
final rootEscapeResult = _handleDesktopRootEscape(event);
@@ -2139,13 +2094,11 @@ class _MainScreenState extends State<MainScreen>
return OverlaySheetHost(
onOpenChanged: _handleOverlaySheetOpenChanged,
// Host owns sheet + system back; onSystemBack mirrors the old PopScope
// (go to home tab, then press-back-twice to exit).
// The route's single back policy for key and system back alike (go to
// the home tab, then press-back-twice to exit). The host closes an open
// sheet first.
canPop: false,
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
_handleMainBack();
},
onBack: _handleMainBack,
child: ScaffoldMessenger(
key: ProfileNavigationScope.of(context).mainScaffoldMessengerKey,
child: PlatformDetector.shouldUseLandscapeNavigationRail(context)
+240 -321
View File
@@ -4,7 +4,6 @@ import '../media/ids.dart';
import 'package:flutter/material.dart';
import '../navigation/profile_navigation_scope.dart';
import '../services/device_performance.dart';
import '../services/fullscreen_state_manager.dart';
import 'package:flutter/services.dart';
@@ -22,7 +21,6 @@ import '../focus/focusable_action_bar.dart';
import '../focus/focusable_wrapper.dart';
import '../focus/hub_vertical_navigation.dart';
import '../focus/locked_hub_controller.dart';
import '../focus/key_event_utils.dart';
import '../focus/input_mode_tracker.dart';
import '../widgets/cast_member_strip.dart';
import '../widgets/focus_builders.dart';
@@ -315,13 +313,7 @@ PageRoute<bool> mediaDetailRoute({
}
class _MediaDetailScreenState extends State<MediaDetailScreen>
with
WatchStateAware,
DeletionAware,
DeletionMirrorsWatchState,
MountedSetStateMixin,
ServerBoundMediaMixin,
RouteAware {
with WatchStateAware, DeletionAware, DeletionMirrorsWatchState, MountedSetStateMixin, ServerBoundMediaMixin {
/// Public input alias — used as the live source of truth until the detail
/// fetch returns. Holds backend-neutral [MediaItem] data.
MediaItem get _metadata => _fullMetadata ?? widget.metadata;
@@ -343,13 +335,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
bool _hasLoadedRelatedHubs = false;
final _tvDetailRailKey = GlobalKey<TvBrowseRailState>();
final _hubFocusMemory = HubFocusMemory();
PageRoute<dynamic>? _route;
RouteObserver<PageRoute<dynamic>>? _routeObserver;
late final ScrollController _scrollController;
final ScrollController _extrasScrollController = ScrollController();
bool _watchStateChanged = false;
final ValueNotifier<double> _scrollOffset = ValueNotifier<double>(0);
bool _suppressBackAfterPop = false;
bool _tvDetailRevealed = false;
bool _tvDetailRevealScheduled = false;
bool _hasLoadedSeasons = false;
@@ -776,53 +765,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_maybeTriggerEpisodePaging();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final routeObserver = ProfileNavigationScope.of(context).routeObserver;
final route = ModalRoute.of(context);
if (route is! PageRoute<dynamic>) return;
if (route == _route && routeObserver == _routeObserver) return;
_routeObserver?.unsubscribe(this);
_route = route;
_routeObserver = routeObserver;
routeObserver.subscribe(this, route);
}
@override
void didPopNext() {
_suppressBackAfterPop = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _suppressBackAfterPop = false;
});
});
}
bool _consumeBackAfterChildPop(KeyEvent event) {
if (!_suppressBackAfterPop || !event.logicalKey.isBackKey) return false;
if (event is KeyUpEvent) _suppressBackAfterPop = false;
return true;
}
KeyEventResult _handleMediaDetailBackKey(FocusNode _, KeyEvent event) {
if (_consumeBackAfterChildPop(event)) return KeyEventResult.handled;
return handleBackKeyNavigation(context, event, result: _watchStateChanged);
}
void _popMediaDetailIfBackNotSuppressed() {
if (_suppressBackAfterPop) {
_suppressBackAfterPop = false;
return;
}
Navigator.pop(context, _watchStateChanged);
}
void _handleMediaDetailSystemBack() {
if (BackKeyCoordinator.consumeIfHandled()) return;
_popMediaDetailIfBackNotSuppressed();
}
bool _isTvDetailReadyToReveal(MediaItem metadata) {
if (_isLoadingMetadata) return false;
if (!_hasLoadedTvDetailSupplementalSections(metadata)) return false;
@@ -874,11 +816,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
});
}
Widget _buildTvDetailRevealGate(Widget child, KeyEventResult Function(FocusNode, KeyEvent) handleBack) {
/// Holds focus while the TV detail is still loading so nothing behind the
/// fade can take it; a Back press here bubbles to the route.
Widget _buildTvDetailRevealGate(Widget child) {
final revealed = _tvDetailRevealed;
return Focus(
canRequestFocus: !revealed,
onKeyEvent: revealed ? null : handleBack,
child: ExcludeFocus(
excluding: !revealed,
child: IgnorePointer(
@@ -902,7 +845,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
for (final source in _watchlistListenedSources) {
source.watchlistChanges.removeListener(_onWatchlistSourceChanged);
}
_routeObserver?.unsubscribe(this);
_scrollController.dispose();
_scrollOffset.dispose();
_tvDetailFocusedEpisode.dispose();
@@ -3207,11 +3149,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
onPressed: () => Navigator.pop(context, _watchStateChanged),
focusNode: _backButtonFocusNode,
);
final loading = ListenableBuilder(
listenable: FullscreenStateManager(),
builder: (context, _) => Focus(
onKeyEvent: _handleMediaDetailBackKey,
child: Scaffold(
// Back — key or system — must pop with the watch-state result, so the
// loading state carries the same PopScope as the loaded screen. iOS
// keeps canPop for the interactive swipe.
return PopScope(
canPop: PlatformDetector.isHandheldIOS(context),
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
Navigator.pop(context, _watchStateChanged);
},
child: ListenableBuilder(
listenable: FullscreenStateManager(),
builder: (context, _) => Scaffold(
appBar: AppBar(
leading: DesktopAppBarSections.buildLeadingSection(leading: backButton, context: context),
leadingWidth: DesktopAppBarSections.calculateLeadingWidthForSection(
@@ -3223,17 +3172,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
),
),
);
final blockSystemBack = InputModeTracker.shouldBlockSystemBack(context);
if (!blockSystemBack) {
return loading;
}
return PopScope(
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _handleMediaDetailSystemBack();
},
child: loading,
);
}
// Determine header height based on screen size. The hero is 60% of the
@@ -3256,252 +3194,241 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final headerHeight = isTv ? size.height : math.max(size.height * 0.6, heroFloor);
if (isTv) {
return _buildTvDetailScreen(context, metadata, _handleMediaDetailBackKey);
return _buildTvDetailScreen(context, metadata);
}
_scheduleInitialMobileDetailFocus(metadata);
final blockSystemBack = InputModeTracker.shouldBlockSystemBack(context);
final content = PrimaryScrollController(
controller: _scrollController,
child: IosStatusBarTapScrollToTop(
controller: _scrollController,
child: OverlaySheetHost(
// blockSystemBack keeps the route from double-popping on Android
// keyboard/TV (the key handler owns dpad back); elsewhere canPop:true
// keeps the iOS swipe-back. The host also closes an open sheet on back.
canPop: !blockSystemBack,
onSystemBack: _handleMediaDetailSystemBack,
child: Focus(
onKeyEvent: _handleMediaDetailBackKey,
child: Scaffold(
body: Stack(
children: [
// Background art sits behind the scroll view so it can be
// taller than the hero sliver without displacing content.
_buildHeroBackdropLayer(context, metadata, size, headerHeight),
// The host owns route back for key and system back alike: a back
// with a sheet open closes it; otherwise the route pops with the
// watch-state result. iOS keeps canPop for the interactive swipe.
canPop: PlatformDetector.isHandheldIOS(context),
onBack: () => Navigator.pop(context, _watchStateChanged),
child: Scaffold(
body: Stack(
children: [
// Background art sits behind the scroll view so it can be
// taller than the hero sliver without displacing content.
_buildHeroBackdropLayer(context, metadata, size, headerHeight),
CustomScrollView(
primary: true,
slivers: [
// Hero header content over the background art
SliverToBoxAdapter(
child: SizedBox(height: headerHeight, child: _buildHeroHeader(context, metadata)),
),
CustomScrollView(
primary: true,
slivers: [
// Hero header content over the background art
SliverToBoxAdapter(
child: SizedBox(height: headerHeight, child: _buildHeroHeader(context, metadata)),
),
// Main content. The side insets add the horizontal safe
// area on top of the 16px margin so landscape phones keep
// the text clear of the cutout, like the hero above.
SliverSafeArea(
top: false,
bottom: false,
sliver: SliverToBoxAdapter(
child: Padding(
// Reduced top inset keeps the Overview/first section
// tight under the hero's action row (the hero already
// contributes its own bottom inset above this).
padding: .fromLTRB(
isTv ? TvLayoutConstants.horizontalInset : 16,
isTv ? 8 : 4,
isTv ? TvLayoutConstants.horizontalInset : 16,
isTv ? 8 : 16,
),
child: Column(
crossAxisAlignment: .start,
children: [
// Summary
if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[
Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle),
const SizedBox(height: 12),
CollapsibleText(
text: metadata.summary!,
maxLines: isMobile ? 6 : 4,
style: theme.textTheme.bodyLarge?.copyWith(height: 1.6),
focusNode: _overviewFocusNode,
skipTraversal: false,
onNavigateUp: () {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
_playButtonFocusNode.requestFocus();
},
onNavigateDown: _focusBelowOverview,
onNavigateLeft: () {},
onNavigateRight: () {},
),
const SizedBox(height: 12),
],
// Main content. The side insets add the horizontal safe
// area on top of the 16px margin so landscape phones keep
// the text clear of the cutout, like the hero above.
SliverSafeArea(
top: false,
bottom: false,
sliver: SliverToBoxAdapter(
child: Padding(
// Reduced top inset keeps the Overview/first section
// tight under the hero's action row (the hero already
// contributes its own bottom inset above this).
padding: .fromLTRB(
isTv ? TvLayoutConstants.horizontalInset : 16,
isTv ? 8 : 4,
isTv ? TvLayoutConstants.horizontalInset : 16,
isTv ? 8 : 16,
),
child: Column(
crossAxisAlignment: .start,
children: [
// Summary
if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[
Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle),
const SizedBox(height: 12),
CollapsibleText(
text: metadata.summary!,
maxLines: isMobile ? 6 : 4,
style: theme.textTheme.bodyLarge?.copyWith(height: 1.6),
focusNode: _overviewFocusNode,
skipTraversal: false,
onNavigateUp: () {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
_playButtonFocusNode.requestFocus();
},
onNavigateDown: _focusBelowOverview,
onNavigateLeft: () {},
onNavigateRight: () {},
),
const SizedBox(height: 12),
],
// Seasons / Episodes (for TV shows and seasons)
if (isShow && !_showEpisodesDirectly) ...[
// Season tabs + inline episodes
if (_isLoadingSeasons)
_sectionLoading
else if (_seasonsLoadFailed)
_sectionError(t.messages.seasonsLoadFailed, () => unawaited(_loadSeasons()))
else if (_seasons.isEmpty)
_sectionEmpty(context, t.messages.noSeasonsFound)
else ...[
Text(
key: _seasonsSectionKey,
t.libraries.groupings.episodes,
style: sectionTitleStyle,
),
const SizedBox(height: 12),
_buildSeasonTabs(),
const SizedBox(height: 16),
if (_isLoadingSeasonEpisodes)
_sectionLoading
else if (_seasonEpisodesFirstPageError && _episodes.isEmpty)
_sectionError(
t.messages.episodesLoadFailed,
() => unawaited(_fetchSeasonEpisodes(_selectedSeasonIndex)),
)
else if (_episodes.isNotEmpty)
_buildEpisodesList()
else
_sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
],
SizedBox(height: isTv ? 24 : 12),
] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[
// Server says flatten — existing behavior unchanged
// Seasons / Episodes (for TV shows and seasons)
if (isShow && !_showEpisodesDirectly) ...[
// Season tabs + inline episodes
if (_isLoadingSeasons)
_sectionLoading
else if (_seasonsLoadFailed)
_sectionError(t.messages.seasonsLoadFailed, () => unawaited(_loadSeasons()))
else if (_seasons.isEmpty)
_sectionEmpty(context, t.messages.noSeasonsFound)
else ...[
Text(
key: _seasonsSectionKey,
t.libraries.groupings.episodes,
style: sectionTitleStyle,
),
const SizedBox(height: 12),
if (_isLoadingSeasons || _isLoadingEpisodes)
_buildSeasonTabs(),
const SizedBox(height: 16),
if (_isLoadingSeasonEpisodes)
_sectionLoading
else if (_allEpisodesPageError && _episodes.isEmpty)
_sectionError(t.messages.episodesLoadFailed, () => unawaited(_fetchAllEpisodes()))
else if (_seasonEpisodesFirstPageError && _episodes.isEmpty)
_sectionError(
t.messages.episodesLoadFailed,
() => unawaited(_fetchSeasonEpisodes(_selectedSeasonIndex)),
)
else if (_episodes.isNotEmpty)
_buildEpisodesList()
else
_sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
SizedBox(height: isTv ? 24 : 12),
],
// Cast
if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[
Text(key: _castSectionKey, t.discover.cast, style: sectionTitleStyle),
const SizedBox(height: 12),
_buildCastSection(metadata),
SizedBox(height: isTv ? 24 : 12),
],
// Trailers & Extras Section
if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[
Text(key: _extrasSectionKey, t.discover.extras, style: sectionTitleStyle),
const SizedBox(height: 12),
_buildExtrasSection(),
SizedBox(height: isTv ? 24 : 12),
],
// Related Hubs (Collections, Similar, More From...)
for (int i = 0; i < _relatedHubs.length; i++) ...[
HubSection(
key: _relatedHubKeys[i],
hub: _relatedHubs[i],
focusMemory: _hubFocusMemory,
icon: _getRelatedHubIcon(_relatedHubs[i]),
inset: true,
onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp),
),
// 8 on mobile: an inset HubSection already carries ~2px of internal
// bottom padding and the next section ~2px on top, so 8 lands on the
// same ~12px rhythm as the sections above.
SizedBox(height: isTv ? 28 : 8),
],
// Additional info — wrapped in Focus so DPAD DOWN from the
// last focusable section lands here and scrolls it into view.
if (_hasInfoRows)
Focus(
focusNode: _infoRowsFocusNode,
onKeyEvent: _handleInfoRowsKeyEvent,
child: Column(
key: _infoRowsSectionKey,
crossAxisAlignment: .start,
children: [
if (metadata.studio != null) ...[
_buildInfoRow(t.discover.studio, metadata.studio!),
const SizedBox(height: 12),
],
if (metadata.directors?.isNotEmpty == true) ...[
_buildInfoRow(
metadata.directors!.length > 1 ? t.discover.directors : t.discover.director,
metadata.directors!.join(', '),
),
const SizedBox(height: 12),
],
if (metadata.contentRating != null) ...[
_buildInfoRow(
t.discover.rating,
formatContentRating(metadata.contentRating!),
),
const SizedBox(height: 12),
],
],
),
),
SizedBox(height: isTv ? 24 : 12),
] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[
// Server says flatten — existing behavior unchanged
Text(key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle),
const SizedBox(height: 12),
if (_isLoadingSeasons || _isLoadingEpisodes)
_sectionLoading
else if (_allEpisodesPageError && _episodes.isEmpty)
_sectionError(t.messages.episodesLoadFailed, () => unawaited(_fetchAllEpisodes()))
else if (_episodes.isNotEmpty)
_buildEpisodesList()
else
_sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
SizedBox(height: isTv ? 24 : 12),
],
),
),
),
),
SliverPadding(padding: .only(bottom: MediaQuery.paddingOf(context).bottom)),
],
),
// Sticky top bar with fading background
Positioned(
top: 0,
left: 0,
right: 0,
child: ValueListenableBuilder<double>(
valueListenable: _scrollOffset,
builder: (context, offset, child) => IgnorePointer(
ignoring: offset < 50,
child: AnimatedOpacity(
opacity: (offset / 100).clamp(0.0, 1.0),
duration: const Duration(milliseconds: 150),
child: child!,
),
),
child: SizedBox(
height: MediaQuery.paddingOf(context).top + 58,
child: RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
theme.scaffoldBackgroundColor.withValues(alpha: 0.8),
theme.scaffoldBackgroundColor.withValues(alpha: 0.5),
theme.scaffoldBackgroundColor.withValues(alpha: 0),
// Cast
if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[
Text(key: _castSectionKey, t.discover.cast, style: sectionTitleStyle),
const SizedBox(height: 12),
_buildCastSection(metadata),
SizedBox(height: isTv ? 24 : 12),
],
// Trailers & Extras Section
if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[
Text(key: _extrasSectionKey, t.discover.extras, style: sectionTitleStyle),
const SizedBox(height: 12),
_buildExtrasSection(),
SizedBox(height: isTv ? 24 : 12),
],
// Related Hubs (Collections, Similar, More From...)
for (int i = 0; i < _relatedHubs.length; i++) ...[
HubSection(
key: _relatedHubKeys[i],
hub: _relatedHubs[i],
focusMemory: _hubFocusMemory,
icon: _getRelatedHubIcon(_relatedHubs[i]),
inset: true,
onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp),
),
// 8 on mobile: an inset HubSection already carries ~2px of internal
// bottom padding and the next section ~2px on top, so 8 lands on the
// same ~12px rhythm as the sections above.
SizedBox(height: isTv ? 28 : 8),
],
// Additional info — wrapped in Focus so DPAD DOWN from the
// last focusable section lands here and scrolls it into view.
if (_hasInfoRows)
Focus(
focusNode: _infoRowsFocusNode,
onKeyEvent: _handleInfoRowsKeyEvent,
child: Column(
key: _infoRowsSectionKey,
crossAxisAlignment: .start,
children: [
if (metadata.studio != null) ...[
_buildInfoRow(t.discover.studio, metadata.studio!),
const SizedBox(height: 12),
],
if (metadata.directors?.isNotEmpty == true) ...[
_buildInfoRow(
metadata.directors!.length > 1 ? t.discover.directors : t.discover.director,
metadata.directors!.join(', '),
),
const SizedBox(height: 12),
],
if (metadata.contentRating != null) ...[
_buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)),
const SizedBox(height: 12),
],
],
),
),
],
stops: const [0.0, 0.3, 1.0],
),
),
),
),
),
// Back button (always visible)
Positioned(
top: 0,
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
AppBarBackButton(
style: BackButtonStyle.circular,
onPressed: () => Navigator.pop(context, _watchStateChanged),
focusNode: _backButtonFocusNode,
SliverPadding(padding: .only(bottom: MediaQuery.paddingOf(context).bottom)),
],
),
// Sticky top bar with fading background
Positioned(
top: 0,
left: 0,
right: 0,
child: ValueListenableBuilder<double>(
valueListenable: _scrollOffset,
builder: (context, offset, child) => IgnorePointer(
ignoring: offset < 50,
child: AnimatedOpacity(
opacity: (offset / 100).clamp(0.0, 1.0),
duration: const Duration(milliseconds: 150),
child: child!,
),
context: context,
)!,
),
child: SizedBox(
height: MediaQuery.paddingOf(context).top + 58,
child: RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
theme.scaffoldBackgroundColor.withValues(alpha: 0.8),
theme.scaffoldBackgroundColor.withValues(alpha: 0.5),
theme.scaffoldBackgroundColor.withValues(alpha: 0),
],
stops: const [0.0, 0.3, 1.0],
),
),
),
),
],
),
),
// Back button (always visible)
Positioned(
top: 0,
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
AppBarBackButton(
style: BackButtonStyle.circular,
onPressed: () => Navigator.pop(context, _watchStateChanged),
focusNode: _backButtonFocusNode,
),
context: context,
)!,
),
],
),
),
),
@@ -3511,11 +3438,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return content;
}
Widget _buildTvDetailScreen(
BuildContext context,
MediaItem metadata,
KeyEventResult Function(FocusNode, KeyEvent) handleBack,
) {
Widget _buildTvDetailScreen(BuildContext context, MediaItem metadata) {
final size = MediaQuery.sizeOf(context);
final detailHubs = _tvDetailHubs(metadata);
if (widget.initialEpisodeId != null && !_initialEpisodePagingDone) {
@@ -3603,7 +3526,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
leadingItemForHub: _tvDetailLeadingItemForHub,
onRetryHub: _retryTvDetailHub,
onNavigateUp: _focusTvDetailActionRow,
onBack: _popMediaDetailIfBackNotSuppressed,
// Back bubbles to the route (pops with the watch-state result).
tallPosterScale: _tvDetailTallPosterScale,
widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
initialHubId: _tvDetailInitialHubId(metadata),
@@ -3615,29 +3538,25 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
],
);
final blockSystemBack = InputModeTracker.shouldBlockSystemBack(context);
final content = OverlaySheetHost(
// blockSystemBack keeps the route from double-popping on Android keyboard/
// TV (the key handler owns dpad back); the host also closes an open sheet.
canPop: !blockSystemBack,
onSystemBack: _handleMediaDetailSystemBack,
child: Focus(
onKeyEvent: handleBack,
child: Scaffold(
body: Stack(
children: [
TvSpotlightBackground(
item: metadata,
client: _getArtworkMediaClient(context),
showInfo: false,
localArtworkPathResolver: widget.isOffline
? (path) => _offlineArtworkCandidatePath(context, path)
: null,
allowNetwork: !widget.isOffline,
),
_buildTvDetailRevealGate(revealContent, handleBack),
],
),
// The host owns route back for key and system back alike: a back with a
// sheet open closes it; otherwise the route pops with the watch-state
// result. Nothing here handles Back keys — unconsumed presses reach the
// navigator, which runs this PopScope.
canPop: PlatformDetector.isHandheldIOS(context),
onBack: () => Navigator.pop(context, _watchStateChanged),
child: Scaffold(
body: Stack(
children: [
TvSpotlightBackground(
item: metadata,
client: _getArtworkMediaClient(context),
showInfo: false,
localArtworkPathResolver: widget.isOffline ? (path) => _offlineArtworkCandidatePath(context, path) : null,
allowNetwork: !widget.isOffline,
),
_buildTvDetailRevealGate(revealContent),
],
),
),
);
@@ -3769,7 +3688,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
onSelect: openDetails,
onNavigateUp: _backButtonFocusNode.requestFocus,
onNavigateDown: _focusTvDetailActionRow,
onBack: _popMediaDetailIfBackNotSuppressed,
// Back bubbles to the route (pops with the watch-state result).
useBackgroundFocus: true,
disableScale: true,
autoScroll: false,
+1 -1
View File
@@ -369,7 +369,7 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
showDownloadStatus: true,
focusNode: focusNodeForIndex(trackIndex, firstItemFocusNode, prefix: 'detail_grid_item'),
onNavigateUp: trackIndex == 0 ? navigateToAppBar : null,
onBack: handleBackFromContent,
onBack: onBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(trackIndex, hasFocus),
onRefresh: updateItem,
onTap: () => unawaited(playTracks(context, tracks: items, startTrack: item, playContext: _playContext)),
+3 -2
View File
@@ -13,7 +13,7 @@ import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_slider.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../focus/back_press.dart';
import '../../i18n/strings.g.dart';
import '../../media/ids.dart';
import '../../media/lyrics.dart';
@@ -1011,6 +1011,7 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
double? _dragValueMs;
bool _focused = false;
final _backGate = BackPressGate();
int _seekRepeatCount = 0;
LogicalKeyboardKey? _seekDirection;
@@ -1067,7 +1068,7 @@ class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
return KeyEventResult.handled;
}
final backResult = handleBackKeyAction(event, widget.onBack);
final backResult = _backGate.handle(event, widget.onBack);
if (backResult != KeyEventResult.ignored) return backResult;
if (!event.isActionable) return KeyEventResult.ignored;
@@ -15,9 +15,9 @@ import '../../utils/continuation_pagination_coordinator.dart';
import '../../utils/music_navigation.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../focus/back_press.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import 'package:provider/provider.dart';
import 'playlist_item_card.dart';
import '../../i18n/strings.g.dart';
@@ -137,6 +137,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
// Focus management for regular (non-smart) reorderable lists
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
final _backGate = BackPressGate();
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry');
// Navigation state for regular (non-smart) playlists
@@ -471,18 +472,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
KeyEventResult _handleListKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final backResult = handleBackKeyAction(event, () {
if (_movingIndex != null) {
// Cancel move mode and suppress route-level back handling.
backHandledByKeyEvent = true;
_cancelMoveMode();
} else {
// Navigate to the app bar and suppress route-level back handling.
handleBackFromContent();
}
});
if (backResult != KeyEventResult.ignored) {
return backResult;
if (key.isBackKey) {
// Owner only while this node itself is focused (rows are virtual): cancel
// move mode, or hand focus to the app bar. From a focused descendant (an
// app bar button, the retry button) or with no app bar actions the press
// bubbles to the route, whose PopScope runs _handleBackNavigation.
if (!_listFocusNode.hasPrimaryFocus) return KeyEventResult.ignored;
return _backGate.handle(event, _movingIndex != null ? () => _cancelMoveMode() : onBackFromContent);
}
if (event is! KeyDownEvent) return KeyEventResult.ignored;
@@ -630,14 +626,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
return false;
}
/// Handle route back from [OverlaySheetHost], extending the mixin with move mode support.
/// The route's back policy (key and system back), extending the mixin with
/// move mode support. Returns true if the route should pop.
bool _handleBackNavigation() {
// If BACK was already handled by a key event, don't pop
if (backHandledByKeyEvent) {
backHandledByKeyEvent = false;
return false;
}
// If in move mode, cancel move instead of navigating
if (_movingIndex != null) {
_cancelMoveMode();
@@ -701,7 +692,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
retryFocusNode: _continuationRetryFocusNode,
errorContext: widget.playlist.title,
onNavigateUp: _isReadOnly ? navigateToGrid : _listFocusNode.requestFocus,
onBack: handleBackFromContent,
onBack: onBackFromContent,
),
],
const SliverSystemBottomInset(),
@@ -714,7 +705,10 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
focusNode: _listFocusNode,
onKeyEvent: _handleListKeyEvent,
onFocusChange: (hasFocus) {
if (hasFocus && mounted) {
if (!hasFocus) _backGate.reset();
// Descendants (the app bar buttons) report through this node too;
// only the list itself taking focus leaves the app bar.
if (hasFocus && mounted && _listFocusNode.hasPrimaryFocus) {
setState(() {
isAppBarFocused = false;
});
@@ -725,13 +719,13 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
}
return OverlaySheetHost(
// The host owns route back for key and system back alike: a back with a
// sheet open closes it; otherwise _handleBackNavigation cancels a move,
// focuses the app bar, or pops. canPop keeps the iOS swipe-back outside
// move mode.
canPop: allowsNativeBackGesture && _movingIndex == null,
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
final shouldPop = _handleBackNavigation();
if (shouldPop && mounted) {
Navigator.pop(context);
}
onBack: () {
if (_handleBackNavigation() && mounted) Navigator.pop(context);
},
child: PrimaryScrollController(
controller: scrollController,
+3 -1
View File
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focus_theme.dart';
import '../../focus/key_event_utils.dart';
import '../../focus/back_press.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
@@ -202,6 +203,7 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin {
];
final List<int?> _digits = [null, null, null, null];
final _backGate = BackPressGate();
int _activeIndex = 0;
int _row = 0;
int _column = 0;
@@ -380,7 +382,7 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin {
KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final backResult = handleBackKeyAction(event, widget.onCancel);
final backResult = _backGate.handle(event, widget.onCancel);
if (backResult != KeyEventResult.ignored) return backResult;
if (event.isTvSelectEvent || (PlatformDetector.isTV() && event.isPhysicalKeyboardEnter)) {
return handleOneShotSelect(event, () => _activate(_rows[_row][_column]));
-3
View File
@@ -11,7 +11,6 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../focus/focusable_action_bar.dart';
import '../../widgets/dialog_action_button.dart';
import '../../widgets/app_icon.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../utils/dialogs.dart';
@@ -367,8 +366,6 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
return Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
final backResult = handleBackKeyNavigation(context, event);
if (backResult != KeyEventResult.ignored) return backResult;
if (event is KeyDownEvent || event is KeyRepeatEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_scroll(80);
+6 -11
View File
@@ -7,7 +7,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/key_event_utils.dart';
import '../../focus/back_press.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../models/mpv_config_models.dart';
@@ -41,6 +41,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
);
final _savePresetFocusNode = FocusNode();
final _textFieldFocusNode = FocusNode();
final _editorBackGate = BackPressGate();
final _saveDebouncer = Debouncer(const Duration(milliseconds: 400));
String _persistedText = '';
int _revision = 0;
@@ -234,8 +235,6 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
!_isLeaving),
onPopInvokedWithResult: (didPop, _) {
if (didPop || _isLeaving) return;
if (BackKeyCoordinator.consumeIfHandled()) return;
BackKeyUpSuppressor.suppressBackUntilKeyUp();
if (_textFieldFocusNode.hasFocus && _savePresetFocusNode.canRequestFocus) {
_savePresetFocusNode.requestFocus();
} else {
@@ -293,18 +292,14 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
return Focus(
canRequestFocus: false,
onKeyEvent: (_, event) {
// Back/Escape: move focus to the save preset button instead of exiting.
// Suppress the KeyUp so it doesn't reach handleBackKeyNavigation
// on the new focus chain after focus moves away from the text field.
// Back/Escape: move focus to the save preset button instead of
// exiting. Acts on KeyDown because a native IME closing on Back may
// swallow the KeyUp; the gate makes the press act exactly once.
if (event.logicalKey.isBackKey) {
if (!_savePresetFocusNode.canRequestFocus) {
return KeyEventResult.ignored;
}
if (event is KeyDownEvent) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
_savePresetFocusNode.requestFocus();
}
return KeyEventResult.handled;
return _editorBackGate.handle(event, _savePresetFocusNode.requestFocus, phase: BackPhase.keyDown);
}
// We must consume Enter to prevent parent handlers from unfocusing,
// but that also blocks Flutter's text editing shortcuts (which are
+1 -1
View File
@@ -166,7 +166,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
final hideChromeOnMouseExit = !(isMobile && !PlatformDetector.isTV());
// Back handling (sheet-close + player exit) is owned by the OverlaySheetHost
// that wraps this widget — see video_player_screen.dart (canPop/onSystemBack).
// that wraps this widget — see video_player_screen.dart (canPop/onBack).
return Scaffold(
// Use transparent background on macOS when native video layer is active
backgroundColor: Colors.transparent,
+28 -15
View File
@@ -110,9 +110,9 @@ import '../widgets/video_controls/video_controls.dart';
import '../widgets/video_controls/widgets/player_toast_indicator.dart';
import '../focus/focusable_button.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/back_press.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_navigation_intent.dart';
import '../focus/key_event_utils.dart';
import '../focus/transport_keys.dart';
import '../i18n/strings.g.dart';
import '../watch_together/providers/watch_together_provider.dart';
@@ -583,6 +583,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// key events never escape the video player route.
late final FocusNode _screenFocusNode;
/// Owns Back presses that reach the screen after the controls declined them:
/// the fullscreen, chrome, prompt and route-exit stages.
final BackPressGate _backGate = BackPressGate();
/// Key for a context below this screen's own [OverlaySheetHost]. The State's
/// context sits ABOVE the host, so resolving the controller with `context`
/// always misses it and the screen would walk its back pipeline (hide chrome,
@@ -1962,17 +1966,30 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
/// Loading and initialization-error phases can receive a Back key-down
/// before their autofocus request has settled. Claim focus immediately so
/// the matching key-up reaches the player route and exits exactly once.
/// before their autofocus request has settled. Claim focus immediately, and
/// when the screen is not yet in the focus chain take that key-down into the
/// screen's own gate: focus dispatch still runs this key-down on the previous
/// chain, so the matching key-up arrives here as an unarmed key-up nobody
/// owns unless the gate has seen the press. Priming lets it exit exactly
/// once.
bool _primeInitializationNavigationFocus(KeyEvent event) {
if (!mounted || _isExiting.value) return false;
primePlayerNavigationFocusForEvent(
final primed = primePlayerNavigationFocusForEvent(
event,
focusNode: _screenFocusNode,
playerReady: _isPlayerInitialized && player != null && _firstFrame.uiReady.value,
isCurrentRoute: ModalRoute.of(context)?.isCurrent ?? true,
isAppleTV: PlatformDetector.isAppleTV(),
);
if (primed && !_screenFocusNode.hasFocus) {
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
handlePlayerNavigationKeyAction(
event,
navigationKey,
() => _handleScreenPlayerNavigation(navigationKey),
backGate: _backGate,
);
}
return false;
}
@@ -2283,13 +2300,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!isCurrentRoute) return KeyEventResult.ignored;
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
if (navigationKey != PlayerNavigationKey.none) {
if (navigationKey != PlayerNavigationKey.home && PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
return handlePlayerNavigationKeyAction(
event,
navigationKey,
() => _handleScreenPlayerNavigation(navigationKey),
backGate: _backGate,
);
}
// Hardware media transport must act even when focus rests on this
@@ -2334,15 +2349,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return KeyEventResult.ignored;
},
child: OverlaySheetHost(
// Host owns sheet + system back: a back with a sheet open closes it;
// with no sheet, exit the player. canPop:false keeps swipe-back disabled
// so it doesn't fight timeline scrubbing.
// Host owns sheet + the route's back policy: a back with a sheet open
// closes it; with no sheet, walk the screen's back stages. Reached by
// system back, or by a Back key press no player handler consumed (the
// navigator turns it into maybePop). canPop:false keeps swipe-back
// disabled so it doesn't fight timeline scrubbing.
canPop: false,
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
BackKeyCoordinator.markHandled();
_handleScreenPlayerNavigation(PlayerNavigationKey.back);
},
onBack: () => _handleScreenPlayerNavigation(PlayerNavigationKey.back),
child: Builder(
key: _overlayChildKey,
builder: (sheetContext) => _isPlayerInitialized && player != null
@@ -4,7 +4,6 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../focus/focus_navigation_intent.dart';
import '../focus/input_mode_tracker.dart';
import '../utils/app_logger.dart';
import '../utils/key_event_simulator.dart' as key_sim;
import 'gamepad_service.dart';
@@ -405,7 +404,6 @@ class AppleTvRemoteTouchService {
return false;
}
InputModeTracker.reportNonPointerInput();
_scheduleFrame();
_log('emit key=${_keyName(logicalKey)} source=$source${detail == null ? '' : ' $detail'}');
_simulateKeyPress(logicalKey);
+84 -74
View File
@@ -7,21 +7,18 @@ import 'package:flutter/widgets.dart';
import 'text_input_diagnostics.dart';
String _describeSimulatedKey(KeyEvent event) {
return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} '
'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType}';
}
void _logKeySimulator(String message) {
TextInputDiagnostics.log('KeySimulator', message);
}
final KeyEventSimulatorController _defaultSimulator = KeyEventSimulatorController();
/// Shared utility for simulating key press events through the focus tree.
/// Shared utility for simulating key press events through the framework's key
/// pipeline.
///
/// Used by companion remotes, Apple TV touch input, and gamepad services to
/// translate external input into focus-tree key events.
/// translate external input into key events that behave exactly like hardware
/// keys.
void simulateKeyPress(LogicalKeyboardKey logicalKey) {
_defaultSimulator.simulateKeyPress(logicalKey);
}
@@ -38,14 +35,34 @@ void simulateKeyUp(LogicalKeyboardKey logicalKey) {
/// Simulates key events for one external input source.
///
/// Separate instances isolate held keys and repeat timers when multiple input
/// sources are active.
/// Every event enters the engine's own key entry point,
/// [ui.PlatformDispatcher.onKeyData], so it runs the complete framework
/// pipeline a hardware key does: [HardwareKeyboard] pressed-key state and every
/// registered hardware handler (input-mode tracking, transport keys, the hotkey
/// recorder), then the focus system's early handlers, the focus chain from the
/// primary focus upward, and its late handlers. A synthetic press is therefore
/// indistinguishable from a hardware press to every consumer, including a KeyUp
/// that lands on whatever holds focus at release time.
///
/// Events are marked [ui.KeyData.synthesized] because that is the framework's
/// documented shape for a key event with no native counterpart: it is
/// dispatched at once instead of being held for the raw-key message that
/// follows every native event. No key consumer in the framework or this app
/// reads the flag.
///
/// The stream is regularized against [HardwareKeyboard]'s pressed state so two
/// sources sharing a physical key cannot produce a double KeyDown or an
/// unmatched KeyUp: a KeyDown for a pressed key becomes a repeat, a KeyUp for
/// an unpressed key is dropped.
///
/// Separate instances isolate repeat timers when multiple input sources are
/// active.
class KeyEventSimulatorController {
final ui.KeyEventDeviceType deviceType;
final Map<LogicalKeyboardKey, PhysicalKeyboardKey> physicalKeyByLogicalKey;
final void Function(String) _log;
final Map<LogicalKeyboardKey, FocusNode> _heldFocusNodes = {};
final Set<LogicalKeyboardKey> _heldKeys = {};
Timer? _repeatTimer;
bool _disposed = false;
@@ -63,50 +80,49 @@ class KeyEventSimulatorController {
if (TextInputDiagnostics.enabled) {
_log('simulateKeyPress scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
}
_schedule((focusNode) {
final physicalKey = _physicalKeyFor(logicalKey);
_dispatchKeyEvent(focusNode, _keyDownEvent(logicalKey, physicalKey));
_dispatchKeyEvent(focusNode, _keyUpEvent(logicalKey, physicalKey));
_schedule(() {
_dispatchKeyDown(logicalKey);
_dispatchKeyUp(logicalKey);
});
}
/// Simulates key down and remembers its focus until key up.
/// Simulates key down; the key stays pressed until [simulateKeyUp].
void simulateKeyDown(LogicalKeyboardKey logicalKey) {
if (_disposed) return;
if (TextInputDiagnostics.enabled) {
_log('simulateKeyDown scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
}
_schedule((focusNode) {
_heldFocusNodes[logicalKey] = focusNode;
_dispatchKeyEvent(focusNode, _keyDownEvent(logicalKey, _physicalKeyFor(logicalKey)));
_schedule(() {
_heldKeys.add(logicalKey);
_dispatchKeyDown(logicalKey);
});
}
/// Simulates key up on the focus that received the matching key down.
/// Simulates key up, delivered to whatever holds focus at release time.
void simulateKeyUp(LogicalKeyboardKey logicalKey) {
if (_disposed) return;
if (TextInputDiagnostics.enabled) {
_log('simulateKeyUp scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
}
scheduleFrameIfIdle();
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!_disposed) _dispatchKeyUp(logicalKey);
_schedule(() {
_heldKeys.remove(logicalKey);
_dispatchKeyUp(logicalKey);
});
}
/// Releases [logicalKeys] together after previously scheduled key downs.
///
/// Any remaining held state is cleared after the release burst.
/// Any key this source still holds afterwards is released as well, so a
/// disconnecting device cannot leave the framework's pressed state stale.
void releaseKeys(Iterable<LogicalKeyboardKey> logicalKeys) {
if (_disposed) return;
final keys = logicalKeys.toList(growable: false);
scheduleFrameIfIdle();
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_disposed) return;
_schedule(() {
for (final logicalKey in keys) {
_heldKeys.remove(logicalKey);
_dispatchKeyUp(logicalKey);
}
_heldFocusNodes.clear();
_releaseHeldKeys();
});
}
@@ -126,82 +142,76 @@ class KeyEventSimulatorController {
_repeatTimer = null;
}
/// Releases every key this source still holds.
void clearHeldKeys() {
_heldFocusNodes.clear();
if (_disposed || _heldKeys.isEmpty) return;
_schedule(_releaseHeldKeys);
}
void dispose() {
if (_disposed) return;
_disposed = true;
stopKeyRepeat();
_heldFocusNodes.clear();
// Held keys are released synchronously: a post-frame callback would be
// skipped by the disposed guard.
_releaseHeldKeys();
}
void _schedule(void Function(FocusNode focusNode) dispatch) {
void _schedule(VoidCallback dispatch) {
// Post-frame dispatch lets focus settle. Requesting a frame is essential
// when external input arrives while Flutter is otherwise idle.
scheduleFrameIfIdle();
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_disposed) return;
final focusNode = FocusManager.instance.primaryFocus;
if (focusNode != null) dispatch(focusNode);
dispatch();
});
}
void _releaseHeldKeys() {
if (_heldKeys.isEmpty) return;
final held = _heldKeys.toList(growable: false);
_heldKeys.clear();
for (final logicalKey in held) {
_dispatchKeyUp(logicalKey);
}
}
void _dispatchKeyDown(LogicalKeyboardKey logicalKey) {
final physicalKey = _physicalKeyFor(logicalKey);
final alreadyPressed = HardwareKeyboard.instance.physicalKeysPressed.contains(physicalKey);
_dispatch(alreadyPressed ? ui.KeyEventType.repeat : ui.KeyEventType.down, logicalKey, physicalKey);
}
void _dispatchKeyUp(LogicalKeyboardKey logicalKey) {
final heldFocusNode = _heldFocusNodes.remove(logicalKey);
final focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus;
if (focusNode == null) return;
if (heldFocusNode != null && heldFocusNode.context == null) {
final physicalKey = _physicalKeyFor(logicalKey);
if (!HardwareKeyboard.instance.physicalKeysPressed.contains(physicalKey)) {
if (TextInputDiagnostics.enabled) {
_log('simulateKeyUp dropped detached held focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
_log('simulateKeyUp dropped unpressed logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
}
return;
}
_dispatchKeyEvent(focusNode, _keyUpEvent(logicalKey, _physicalKeyFor(logicalKey)));
_dispatch(ui.KeyEventType.up, logicalKey, physicalKey);
}
KeyDownEvent _keyDownEvent(LogicalKeyboardKey logicalKey, PhysicalKeyboardKey physicalKey) {
return KeyDownEvent(
physicalKey: physicalKey,
logicalKey: logicalKey,
void _dispatch(ui.KeyEventType type, LogicalKeyboardKey logicalKey, PhysicalKeyboardKey physicalKey) {
final data = ui.KeyData(
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
type: type,
physical: physicalKey.usbHidUsage,
logical: logicalKey.keyId,
character: null,
synthesized: true,
deviceType: deviceType,
);
}
KeyUpEvent _keyUpEvent(LogicalKeyboardKey logicalKey, PhysicalKeyboardKey physicalKey) {
return KeyUpEvent(
physicalKey: physicalKey,
logicalKey: logicalKey,
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
deviceType: deviceType,
);
}
void _dispatchKeyEvent(FocusNode focusNode, KeyEvent event) {
if (TextInputDiagnostics.enabled) {
_log('dispatch start focus=${focusNode.debugLabel} key=(${_describeSimulatedKey(event)})');
_log(
'dispatch type=$type focus=${FocusManager.instance.primaryFocus?.debugLabel} '
'logical=${logicalKey.keyLabel}/${logicalKey.keyId} physical=${physicalKey.usbHidUsage} deviceType=$deviceType',
);
}
FocusNode? node = focusNode;
while (node != null) {
if (node.onKeyEvent != null) {
final result = node.onKeyEvent!(node, event);
if (TextInputDiagnostics.enabled) {
_log('dispatch node=${node.debugLabel} result=$result key=(${_describeSimulatedKey(event)})');
}
if (result != KeyEventResult.ignored) {
if (TextInputDiagnostics.enabled) _log('dispatch stopped node=${node.debugLabel} result=$result');
break;
}
}
node = node.parent;
}
if (node == null) {
if (TextInputDiagnostics.enabled) {
_log('dispatch reached root ignored key=(${_describeSimulatedKey(event)})');
}
final handled = ui.PlatformDispatcher.instance.onKeyData?.call(data) ?? false;
if (TextInputDiagnostics.enabled) {
_log('dispatch done type=$type handled=$handled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
}
}
-2
View File
@@ -598,8 +598,6 @@ class _AppMenuPopupState<T> extends State<_AppMenuPopup<T>> {
skipTraversal: true,
onKeyEvent: (node, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled;
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) return KeyEventResult.handled;
if (event.logicalKey.isBackKey) return handleBackKeyAction(event, () => Navigator.pop(context));
return KeyEventResult.ignored;
},
child: Listener(
+2 -13
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart';
import '../focus/back_press.dart';
import 'bottom_sheet_header.dart';
/// Shared page layout for bottom sheets with a stable header and content area.
@@ -77,17 +76,7 @@ class BottomSheetPageScaffold extends StatelessWidget {
// the whole sheet via the overlay host.
final back = onBack;
if (back != null) {
content = Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) {
if (event.logicalKey.isBackKey) {
return handleBackKeyAction(event, back);
}
return KeyEventResult.ignored;
},
child: content,
);
content = BackKeyOwner(onBack: back, child: content);
}
return content;
+66 -70
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_text_field.dart';
import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart';
import '../media/library_query.dart';
import '../media/media_item.dart';
@@ -141,80 +140,77 @@ class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
@override
Widget build(BuildContext context) {
final showStatus = _hasMore || _isLoading || _errorMessage != null || _filteredItems.isEmpty;
return Focus(
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
child: AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: .min,
children: [
if (_showFilter) ...[
FocusableTextField(
controller: _filterController,
focusNode: _filterFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
onNavigateDown: _firstItemFocusNode.requestFocus,
decoration: pillInputDecoration(
context,
hintText: widget.searchHint,
prefixIcon: const AppIcon(Symbols.search_rounded, size: 20),
),
onChanged: _onFilterChanged,
),
const SizedBox(height: 8),
],
Flexible(
child: ListView.builder(
controller: _scrollController,
shrinkWrap: true,
itemCount: _filteredItems.length + 1 + (showStatus ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0) {
return FocusableListTile(
focusNode: _firstItemFocusNode,
leading: const AppIcon(Symbols.add_rounded, fill: 1),
title: Text(t.common.createNew),
onTap: () => Navigator.pop(context, '_create_new'),
);
}
if (index <= _filteredItems.length) {
return widget.itemBuilder(context, _filteredItems[index - 1]);
}
if (_errorMessage != null) {
return FocusableListTile(
leading: const AppIcon(Symbols.error_rounded, fill: 1),
title: Text(t.messages.errorLoading(error: _errorMessage!)),
onTap: _loadNextPage,
);
}
if (_hasMore || _isLoading) {
if (_hasMore && !_isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_loadNextPage());
});
}
return const Padding(
padding: .all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return Padding(
padding: const .all(16),
child: Text(widget.emptyMessage, textAlign: TextAlign.center),
);
},
return AlertDialog(
title: Text(widget.title),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: .min,
children: [
if (_showFilter) ...[
FocusableTextField(
controller: _filterController,
focusNode: _filterFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
onNavigateDown: _firstItemFocusNode.requestFocus,
decoration: pillInputDecoration(
context,
hintText: widget.searchHint,
prefixIcon: const AppIcon(Symbols.search_rounded, size: 20),
),
onChanged: _onFilterChanged,
),
const SizedBox(height: 8),
],
),
Flexible(
child: ListView.builder(
controller: _scrollController,
shrinkWrap: true,
itemCount: _filteredItems.length + 1 + (showStatus ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0) {
return FocusableListTile(
focusNode: _firstItemFocusNode,
leading: const AppIcon(Symbols.add_rounded, fill: 1),
title: Text(t.common.createNew),
onTap: () => Navigator.pop(context, '_create_new'),
);
}
if (index <= _filteredItems.length) {
return widget.itemBuilder(context, _filteredItems[index - 1]);
}
if (_errorMessage != null) {
return FocusableListTile(
leading: const AppIcon(Symbols.error_rounded, fill: 1),
title: Text(t.messages.errorLoading(error: _errorMessage!)),
onTap: _loadNextPage,
);
}
if (_hasMore || _isLoading) {
if (_hasMore && !_isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_loadNextPage());
});
}
return const Padding(
padding: .all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return Padding(
padding: const .all(16),
child: Text(widget.emptyMessage, textAlign: TextAlign.center),
);
},
),
),
],
),
actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)],
),
actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)],
);
}
}
@@ -10,7 +10,6 @@ import '../../services/settings_service.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/dialogs.dart';
import '../../focus/focusable_button.dart';
import '../../focus/key_event_utils.dart';
import '../dialog_action_button.dart';
import '../app_icon.dart';
@@ -93,138 +92,134 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> with MountedS
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
onKeyEvent: (node, event) => handleBackKeyNavigation(context, event),
child: Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
if (_isStarting) {
return Dialog(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisSize: .min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(t.companionRemote.session.startingServer, style: Theme.of(context).textTheme.titleMedium),
],
),
),
);
}
if (_errorMessage != null) {
return AlertDialog(
title: Text(t.common.error),
content: Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')),
actions: [
DialogActionButton(
autofocus: true,
focusNode: _errorCloseFocusNode,
onPressed: _close,
onBack: _close,
onNavigateRight: () => _errorRetryFocusNode.requestFocus(),
useBackgroundFocus: true,
label: t.common.close,
),
DialogActionButton(
focusNode: _errorRetryFocusNode,
onPressed: _startServer,
onBack: _close,
onNavigateLeft: () => _errorCloseFocusNode.requestFocus(),
useBackgroundFocus: true,
label: t.common.retry,
),
],
);
}
return Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
if (_isStarting) {
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Row(
children: [
const AppIcon(Symbols.phone_android_rounded, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text(t.companionRemote.title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
_buildStatusLine(context, provider),
],
),
),
FocusableButton(
focusNode: _closeFocusNode,
onPressed: _close,
onBack: _close,
onNavigateDown: () => _toggleFocusNode.requestFocus(),
useBackgroundFocus: true,
child: IconButton(icon: const AppIcon(Symbols.close_rounded), onPressed: _close),
),
],
),
const SizedBox(height: 24),
_buildServerStatus(context, provider),
if (provider.connectedDevice != null) ...[
const SizedBox(height: 16),
_buildConnectedDevice(context, provider),
],
const SizedBox(height: 24),
Row(
mainAxisAlignment: .end,
children: [
FocusableButton(
autofocus: true,
focusNode: _toggleFocusNode,
onPressed: _toggleServer,
onBack: _close,
onNavigateUp: () => _closeFocusNode.requestFocus(),
onNavigateRight: () => _minimizeFocusNode.requestFocus(),
useBackgroundFocus: true,
child: TextButton.icon(
onPressed: _toggleServer,
icon: AppIcon(
provider.isHostServerRunning ? Symbols.stop_rounded : Symbols.play_arrow_rounded,
),
label: Text(
provider.isHostServerRunning
? t.companionRemote.session.stopServer
: t.companionRemote.session.startServer,
),
),
),
const SizedBox(width: 8),
FocusableButton(
focusNode: _minimizeFocusNode,
onPressed: _close,
onBack: _close,
onNavigateUp: () => _closeFocusNode.requestFocus(),
onNavigateLeft: () => _toggleFocusNode.requestFocus(),
useBackgroundFocus: true,
child: FilledButton(onPressed: _close, child: Text(t.companionRemote.session.minimize)),
),
],
),
],
),
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisSize: .min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(t.companionRemote.session.startingServer, style: Theme.of(context).textTheme.titleMedium),
],
),
),
);
},
),
}
if (_errorMessage != null) {
return AlertDialog(
title: Text(t.common.error),
content: Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')),
actions: [
DialogActionButton(
autofocus: true,
focusNode: _errorCloseFocusNode,
onPressed: _close,
onBack: _close,
onNavigateRight: () => _errorRetryFocusNode.requestFocus(),
useBackgroundFocus: true,
label: t.common.close,
),
DialogActionButton(
focusNode: _errorRetryFocusNode,
onPressed: _startServer,
onBack: _close,
onNavigateLeft: () => _errorCloseFocusNode.requestFocus(),
useBackgroundFocus: true,
label: t.common.retry,
),
],
);
}
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Row(
children: [
const AppIcon(Symbols.phone_android_rounded, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text(t.companionRemote.title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
_buildStatusLine(context, provider),
],
),
),
FocusableButton(
focusNode: _closeFocusNode,
onPressed: _close,
onBack: _close,
onNavigateDown: () => _toggleFocusNode.requestFocus(),
useBackgroundFocus: true,
child: IconButton(icon: const AppIcon(Symbols.close_rounded), onPressed: _close),
),
],
),
const SizedBox(height: 24),
_buildServerStatus(context, provider),
if (provider.connectedDevice != null) ...[
const SizedBox(height: 16),
_buildConnectedDevice(context, provider),
],
const SizedBox(height: 24),
Row(
mainAxisAlignment: .end,
children: [
FocusableButton(
autofocus: true,
focusNode: _toggleFocusNode,
onPressed: _toggleServer,
onBack: _close,
onNavigateUp: () => _closeFocusNode.requestFocus(),
onNavigateRight: () => _minimizeFocusNode.requestFocus(),
useBackgroundFocus: true,
child: TextButton.icon(
onPressed: _toggleServer,
icon: AppIcon(
provider.isHostServerRunning ? Symbols.stop_rounded : Symbols.play_arrow_rounded,
),
label: Text(
provider.isHostServerRunning
? t.companionRemote.session.stopServer
: t.companionRemote.session.startServer,
),
),
),
const SizedBox(width: 8),
FocusableButton(
focusNode: _minimizeFocusNode,
onPressed: _close,
onBack: _close,
onNavigateUp: () => _closeFocusNode.requestFocus(),
onNavigateLeft: () => _toggleFocusNode.requestFocus(),
useBackgroundFocus: true,
child: FilledButton(onPressed: _close, child: Text(t.companionRemote.session.minimize)),
),
],
),
],
),
),
),
);
},
);
}
+8 -8
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../focus/back_press.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import 'desktop_app_bar.dart';
import 'ios_status_bar_tap_scroll_to_top.dart';
import 'system_bottom_inset.dart';
@@ -41,9 +41,9 @@ class FocusedScrollScaffold extends StatefulWidget {
/// Defaults to true.
final bool automaticallyImplyLeading;
/// Optional override for the back key handler.
/// When set, this callback is invoked instead of the default
/// [handleBackKeyNavigation] (which pops the current route).
/// Consumes Back for the screen instead of letting it bubble to the
/// navigator, which pops the route. Use only when Back does something other
/// than pop; a screen that must veto the pop uses `PopScope`.
final VoidCallback? onBackPressed;
const FocusedScrollScaffold({
@@ -63,6 +63,7 @@ class FocusedScrollScaffold extends StatefulWidget {
class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
final _scopeNode = FocusScopeNode();
final _backGate = BackPressGate();
bool _focusRequested = false;
bool _appBarFocusEnabled = false;
@@ -95,10 +96,9 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
return Focus(
canRequestFocus: false,
onKeyEvent: (_, event) {
if (widget.onBackPressed != null) {
return handleBackKeyAction(event, widget.onBackPressed!);
}
return handleBackKeyNavigation(context, event);
final onBack = widget.onBackPressed;
if (onBack == null) return KeyEventResult.ignored;
return _backGate.handle(event, onBack);
},
child: FocusScope(
node: _scopeNode,
+3 -2
View File
@@ -9,7 +9,7 @@ import '../focus/dpad_select_long_press_controller.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/focus_navigation_intent.dart';
import '../focus/key_event_utils.dart';
import '../focus/back_press.dart';
import '../services/settings_service.dart';
import 'settings_builder.dart';
import '../utils/layout_constants.dart';
@@ -131,6 +131,7 @@ class HubSection extends StatefulWidget {
class HubSectionState extends State<HubSection> with MountedSetStateMixin, SkeletonUpgradeScheduler {
late FocusNode _hubFocusNode;
final ScrollController _scrollController = ScrollController();
final _backGate = BackPressGate();
/// Current visual focus index (not tied to Flutter's focus system)
int _focusedIndex = 0;
@@ -312,7 +313,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
if (selectResult != KeyEventResult.ignored) return selectResult;
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
+3 -1
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import '../../focus/focus_theme.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../focus/back_press.dart';
import '../../i18n/strings.g.dart';
import '../../media/lyrics.dart';
import '../../screens/libraries/state_messages.dart';
@@ -45,6 +46,7 @@ class _LyricsViewState extends State<LyricsView> {
static const double _unsyncedScrollStep = 64;
final ScrollController _scroll = ScrollController();
final _backGate = BackPressGate();
Lyrics? _lyrics;
bool _loading = true;
@@ -220,7 +222,7 @@ class _LyricsViewState extends State<LyricsView> {
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final onExit = widget.onExit;
if (onExit != null) {
final backResult = handleBackKeyAction(event, onExit);
final backResult = _backGate.handle(event, onExit);
if (backResult != KeyEventResult.ignored) return backResult;
}
return dpadKeyHandler(
+3 -1
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../../focus/focusable_tile_mixin.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../focus/back_press.dart';
import '../../media/media_item.dart';
import '../../mixins/context_menu_tap_mixin.dart';
import '../../models/download_models.dart';
@@ -119,6 +120,7 @@ class _TrackRowState extends State<TrackRow> with ContextMenuTapMixin<TrackRow>,
/// 0 = row (SELECT plays), 1 = ⋮ button (SELECT opens the context menu).
int _focusedColumn = 0;
bool _hasFocus = false;
final _backGate = BackPressGate();
@override
FocusNode? get widgetFocusNode => widget.focusNode;
@@ -144,7 +146,7 @@ class _TrackRowState extends State<TrackRow> with ContextMenuTapMixin<TrackRow>,
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
return dpadKeyHandler(
+28 -45
View File
@@ -5,9 +5,9 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/back_press.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../utils/platform_detector.dart';
/// Entry in the sheet page stack.
@@ -209,7 +209,6 @@ class OverlaySheetController {
showDragHandle: showDragHandle,
);
}
BackKeyCoordinator.clear();
return showAdaptive<T>(
context,
builder: builder,
@@ -253,12 +252,13 @@ class OverlaySheetController {
///
/// ## Back handling
///
/// The host already owns the dpad/key back path (its sheet [FocusScope] closes
/// the sheet on BACK when focus is inside it). For the system/route back path
/// (Android gesture, iOS swipe, predictive back), opt in via [canPop]: the host
/// then renders its own [PopScope] that closes an open sheet instead of popping
/// the screen, so callers don't have to hand-roll it. When [canPop] is null
/// (the default) the host adds no [PopScope] and behaves exactly as before.
/// The host owns the key back path while focus is inside a sheet: its sheet
/// [FocusScope] pops the sub-page or closes the sheet. For the route back path
/// — a system gesture, or a Back key that bubbled to [NavigatorBackHandler]
/// and became `Navigator.maybePop` — opt in via [canPop]: the host then renders
/// its own [PopScope] that closes an open sheet instead of popping the screen,
/// so callers don't have to hand-roll it. When [canPop] is null (the default)
/// the host adds no [PopScope].
class OverlaySheetHost extends StatefulWidget {
final Widget child;
final ValueChanged<bool>? onOpenChanged;
@@ -267,21 +267,20 @@ class OverlaySheetHost extends StatefulWidget {
/// business rule, mirroring [PopScope.canPop]).
///
/// When non-null the host installs a [PopScope]:
/// - a system back with a sheet open closes the sheet (never pops the screen);
/// - a back with a sheet open closes the sheet (never pops the screen);
/// - otherwise, if `canPop` is true the route pops natively (preserving the
/// iOS interactive swipe-back), and if false [onSystemBack] runs instead.
/// iOS interactive swipe-back), and if false [onBack] runs instead.
///
/// When null (default) the host installs no [PopScope] — today's behavior.
/// When null (default) the host installs no [PopScope].
final bool? canPop;
/// Called for a system/route back when no sheet is open and [canPop] is false.
/// Not called when a sheet is open (the sheet is closed instead) or when
/// [canPop] allows a native pop. Implementations that also have a dpad key
/// handler should start with `if (BackKeyCoordinator.consumeIfHandled()) return;`
/// so the system path dedups against the key path.
final VoidCallback? onSystemBack;
/// The screen's back policy: called for a back — key or system — when no
/// sheet is open and [canPop] is false. This is the single place a screen
/// decides what Back does; do not also handle Back keys in a `Focus` above
/// the host.
final VoidCallback? onBack;
const OverlaySheetHost({super.key, required this.child, this.onOpenChanged, this.canPop, this.onSystemBack});
const OverlaySheetHost({super.key, required this.child, this.onOpenChanged, this.canPop, this.onBack});
@override
State<OverlaySheetHost> createState() => _OverlaySheetHostState();
@@ -295,6 +294,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
final List<_OverlaySheetEntry> _pageStack = [];
final _sheetFocusScopeNode = FocusScopeNode(debugLabel: 'OverlaySheetScope');
final _backGate = BackPressGate();
bool _isOpen = false;
bool _isClosing = false;
@@ -364,7 +364,6 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
Alignment alignment = Alignment.bottomCenter,
bool showDragHandle = false,
}) {
BackKeyCoordinator.clear();
// If already open, close first (instant)
final wasOpen = _isOpen;
if (_isOpen) {
@@ -400,7 +399,6 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
OverlaySheetController.openSheetCount.value++;
}
BackKeyUpSuppressor.clearSuppression();
_animationController.forward(from: 0);
_autoFocus();
@@ -535,17 +533,10 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
return KeyEventResult.handled;
}
// Suppress stale back key-ups
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// Back key: pop sub-page or close sheet
// Back key: pop sub-page or close sheet. The gate ignores a release whose
// press acted elsewhere (the sheet opened from a KeyDown-fired action).
if (event.logicalKey.isBackKey) {
if (PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
return handleBackKeyAction(event, _handleBack);
return _backGate.handle(event, _handleBack);
}
// Let all other keys pass through. Directional keys need to reach
@@ -592,12 +583,12 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
),
);
// When a screen opts in via [canPop], the host owns the system/route back
// path so callers don't hand-roll it: a back with a sheet open closes the
// sheet (sub-page aware, matching the dpad path) instead of popping the
// screen; otherwise the route pops natively (canPop true) or [onSystemBack]
// runs (canPop false). `!_isClosing` lets a press during the ~250ms close
// animation fall through instead of being swallowed.
// When a screen opts in via [canPop], the host owns the route back path so
// callers don't hand-roll it: a back with a sheet open closes the sheet
// (sub-page aware, matching the dpad path) instead of popping the screen;
// otherwise the route pops natively (canPop true) or [onBack] runs (canPop
// false). `!_isClosing` lets a press during the ~250ms close animation fall
// through instead of being swallowed.
final canPop = widget.canPop;
if (canPop != null) {
content = PopScope(
@@ -605,18 +596,10 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (_isOpen && !_isClosing) {
// Only TV routes one Back through both the focused key path and
// the platform pop, so only TV needs to dedup them. Touch
// platforms never deliver Back to [_handleKeyEvent], so consulting
// the global marker here could only ever consume some unrelated
// widget's mark and swallow the one signal that closes the sheet.
// The marker is global and one-shot, so anything left set by
// another handler would strand the sheet open with no way out.
if (PlatformDetector.isTV() && BackKeyCoordinator.consumeIfHandled()) return;
_handleBack();
return;
}
widget.onSystemBack?.call();
widget.onBack?.call();
},
child: content,
);
+3 -2
View File
@@ -6,7 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../exceptions/media_server_exceptions.dart';
import '../focus/key_event_utils.dart';
import '../focus/back_press.dart';
import '../i18n/strings.g.dart';
import '../theme/mono_tokens.dart';
import 'package:plezy/widgets/app_icon.dart';
@@ -42,6 +42,7 @@ class _PanelData {
class ServerActivitiesButtonState extends State<ServerActivitiesButton> {
final _buttonKey = GlobalKey();
final _backGate = BackPressGate();
OverlayEntry? _overlayEntry;
final _panelNotifier = ValueNotifier<_PanelData>(_PanelData.loading);
Timer? _pollTimer;
@@ -202,7 +203,7 @@ class ServerActivitiesButtonState extends State<ServerActivitiesButton> {
top: top,
child: Focus(
autofocus: true,
onKeyEvent: (_, event) => handleBackKeyAction(event, _removeOverlay),
onKeyEvent: (_, event) => _backGate.handle(event, _removeOverlay),
child: ValueListenableBuilder<_PanelData>(
valueListenable: _panelNotifier,
builder: (context, data, _) => _buildPanel(context, data),
+3 -2
View File
@@ -9,7 +9,7 @@ import '../focus/card_focus_scope.dart';
import '../focus/dpad_navigator.dart';
import '../focus/dpad_select_long_press_controller.dart';
import '../focus/focus_theme.dart';
import '../focus/key_event_utils.dart';
import '../focus/back_press.dart';
import '../focus/focus_navigation_intent.dart';
import '../focus/locked_hub_controller.dart';
import '../i18n/strings.g.dart';
@@ -397,6 +397,7 @@ class TvBrowseRail extends StatefulWidget {
}
class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixin {
final _backGate = BackPressGate();
// Per-step scroll glide; platform-specific, see
// FocusTheme.navigationScrollDuration. Successive steps — including
// hold-repeats — retarget the animation from the row's current offset, so a
@@ -738,7 +739,7 @@ class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixi
}
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
final backResult = _backGate.handle(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) {
_hasUserInteracted = true;
return backResult;
+3 -2
View File
@@ -5,7 +5,7 @@ import '../i18n/strings.g.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../focus/back_press.dart';
import '../focus/key_repeat_helper.dart';
import 'app_icon.dart';
import '../theme/mono_tokens.dart';
@@ -87,6 +87,7 @@ class TvNumberSpinner extends StatefulWidget {
class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<TvNumberSpinner> {
late FocusNode _focusNode;
bool _isFocused = false;
final _backGate = BackPressGate();
@override
void initState() {
@@ -121,7 +122,7 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
final vertical = widget.verticalKeysAdjustValue;
if (widget.onCancel != null) {
final backResult = handleBackKeyAction(event, widget.onCancel!);
final backResult = _backGate.handle(event, widget.onCancel!);
if (backResult != KeyEventResult.ignored) {
return backResult;
}
@@ -80,13 +80,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
if (navigationKey == PlayerNavigationKey.none || navigationKey == PlayerNavigationKey.home) {
return KeyEventResult.ignored;
}
if (PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
final sheetController = OverlaySheetController.maybeOf(context);
if (sheetController?.isOpen ?? false) {
return handlePlayerNavigationKeyAction(event, navigationKey, sheetController!.pop);
return handlePlayerNavigationKeyAction(event, navigationKey, sheetController!.pop, backGate: _backGate);
}
if (widget.chromeController.contentStripVisible) {
@@ -94,7 +91,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
_desktopControlsKey.currentState?.dismissContentStrip();
widget.chromeController.setContentStripVisible(false);
_restartHideTimerForCurrentPlaybackState();
});
}, backGate: _backGate);
}
// A skip prompt is a local layer like the sheet and the content strip
@@ -104,12 +101,14 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
// of the episode.
//
// The claim is latched for the whole press rather than re-derived per
// event. handleBackKeyAction consumes the key-down and acts on the key-up,
// and the button's own 7s dismiss timer — armed for every prompt while
// auto-skip is off, which is the default — can fire in between. Asking the
// full question again on the key-up would hand that press back to the
// screen and exit the player, which is the bug this stage exists to
// prevent.
// event. The gate consumes the key-down and acts on the platform's phase
// (key-up everywhere but Apple TV), and the button's own 7s dismiss timer
// — armed for every prompt while auto-skip is off, which is the default —
// can fire in between. Asking the full question again on the key-up would
// decline the press here, leaving a key-up no owner armed for: the screen
// gate ignores it and the navigator swallows it, so the press would do
// nothing at all. Keeping the claim lets the same owner that armed the
// press finish it.
//
// The button vanishing is the only race the latch covers. The chrome
// coming up, or a prompt opening, genuinely moves the key back to the
@@ -126,11 +125,14 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
: _skipMarkerOwnsBackPress && !_showControls && !widget.playbackPromptOpen;
_skipMarkerOwnsBackPress = event is KeyUpEvent ? false : skipMarkerOwnsPress;
if (skipMarkerOwnsPress) {
return handlePlayerNavigationKeyAction(event, navigationKey, _dismissSkipMarker);
return handlePlayerNavigationKeyAction(event, navigationKey, _dismissSkipMarker, backGate: _backGate);
}
// The enclosing player screen is the sole owner of fullscreen, chrome,
// prompt, and route-exit stages.
// prompt, and route-exit stages. A press released here mid-flight (a stage
// that armed the gate on key-down closed before the key-up) is over for
// this owner: forget it so no later stray key-up can fire its action.
_backGate.reset();
return KeyEventResult.ignored;
}
+22 -18
View File
@@ -39,6 +39,7 @@ import '../../mixins/settings_effect_mixin.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../mpv/mpv.dart';
import '../overlay_sheet.dart';
import '../../focus/back_press.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focus_navigation_intent.dart';
import '../../focus/transport_keys.dart';
@@ -484,8 +485,11 @@ PlayerNavigationKey classifyPlayerNavigationKey(
}
/// Gives the player route ownership of navigation that arrives while its
/// loading/error body is still establishing focus. The matching key-up still
/// performs the action through the normal [Focus.onKeyEvent] path.
/// loading/error body is still establishing focus. Returns true when focus was
/// claimed for a navigation key-down; the caller then feeds that key-down to
/// its own [BackPressGate], since focus dispatch still runs it on the previous
/// chain, so the matching key-up performs the action through the normal
/// [Focus.onKeyEvent] path exactly once.
bool primePlayerNavigationFocusForEvent(
KeyEvent event, {
required FocusNode focusNode,
@@ -501,27 +505,23 @@ bool primePlayerNavigationFocusForEvent(
return true;
}
/// Performs [onAction] once for a player navigation key press.
///
/// Back keys go through the caller's [backGate], so the action runs once per
/// physical press on the platform's phase and never on a KeyUp whose KeyDown
/// another owner consumed. Home and physical Backspace are player-only
/// navigation keys with no system-back counterpart: they act on KeyUp and the
/// whole press is consumed.
KeyEventResult handlePlayerNavigationKeyAction(
KeyEvent event,
PlayerNavigationKey navigationKey,
VoidCallback onAction,
) {
VoidCallback onAction, {
required BackPressGate backGate,
}) {
if (navigationKey == PlayerNavigationKey.none) return KeyEventResult.ignored;
if (event.logicalKey.isBackKey) return backGate.handle(event, onAction);
// macOS may also translate Backspace / Escape / browser Back into a route
// pop on key-down. Suppress that parallel path before the player performs
// its single staged action on key-up.
if (navigationKey != PlayerNavigationKey.home && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
if (event.logicalKey.isBackKey) return handleBackKeyAction(event, onAction);
if (event is KeyUpEvent) {
if (navigationKey != PlayerNavigationKey.home) {
BackKeyCoordinator.markHandled();
}
onAction();
}
if (event is KeyUpEvent) onAction();
return KeyEventResult.handled;
}
@@ -846,6 +846,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// Current marker state
MediaMarker? _currentMarker;
/// Owns Back presses the controls consume before the screen: sheet, content
/// strip and skip-marker stages in [_handleLocalPlayerNavigationKeyEvent].
final BackPressGate _backGate = BackPressGate();
/// Latched for the whole Back press; see [_handleLocalPlayerNavigationKeyEvent].
bool _skipMarkerOwnsBackPress = false;
late List<MediaMarker> _markers = widget.initialMarkers ?? [];
@@ -3,8 +3,8 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import '../../../focus/back_press.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../focus/key_event_utils.dart';
import '../../../services/settings_service.dart';
import '../../../services/video_volume_controller.dart';
import '../../../i18n/strings.g.dart';
@@ -48,6 +48,9 @@ class _VolumeControlState extends State<VolumeControl> {
/// Whether we're in volume adjust mode (left/right adjusts volume).
bool _isAdjustMode = false;
/// Owns the Back press that leaves adjust mode.
final BackPressGate _backGate = BackPressGate();
/// Volume step size for keyboard adjustment.
static const double _volumeStep = 5.0;
@@ -70,7 +73,7 @@ class _VolumeControlState extends State<VolumeControl> {
if (_isAdjustMode) {
if (key.isBackKey) {
return handleBackKeyAction(event, _exitAdjustMode);
return _backGate.handle(event, _exitAdjustMode);
}
// Notify activity on any key in adjust mode (to reset hide timer)
@@ -109,9 +112,11 @@ class _VolumeControlState extends State<VolumeControl> {
}
void _handleFocusChange(bool hasFocus) {
// Exit adjust mode when focus is lost
if (!hasFocus && _isAdjustMode) {
_exitAdjustMode();
// Exit adjust mode when focus is lost; a Back press in flight then belongs
// to whoever gets focus next, not to this control.
if (!hasFocus) {
_backGate.reset();
if (_isAdjustMode) _exitAdjustMode();
}
widget.onFocusChange?.call(hasFocus);
}
+113
View File
@@ -0,0 +1,113 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/back_press.dart';
import 'package:plezy/utils/platform_detector.dart';
KeyDownEvent _down(LogicalKeyboardKey key) =>
KeyDownEvent(physicalKey: PhysicalKeyboardKey.escape, logicalKey: key, timeStamp: Duration.zero);
KeyRepeatEvent _repeat(LogicalKeyboardKey key) =>
KeyRepeatEvent(physicalKey: PhysicalKeyboardKey.escape, logicalKey: key, timeStamp: Duration.zero);
KeyUpEvent _up(LogicalKeyboardKey key) =>
KeyUpEvent(physicalKey: PhysicalKeyboardKey.escape, logicalKey: key, timeStamp: Duration.zero);
void main() {
setUp(() => TvDetectionService.debugSetAppleTVOverride(false));
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
group('BackPressGate', () {
test('a full press acts once, on KeyUp, and consumes every phase', () {
final gate = BackPressGate();
var fired = 0;
void onBack() => fired++;
expect(gate.handle(_down(LogicalKeyboardKey.goBack), onBack), KeyEventResult.handled);
expect(fired, 0);
expect(gate.handle(_repeat(LogicalKeyboardKey.goBack), onBack), KeyEventResult.handled);
expect(fired, 0);
expect(gate.handle(_up(LogicalKeyboardKey.goBack), onBack), KeyEventResult.handled);
expect(fired, 1);
expect(gate.isArmed, isFalse);
});
test('a KeyUp whose KeyDown landed elsewhere is ignored, not acted on', () {
final gate = BackPressGate();
var fired = 0;
expect(gate.handle(_up(LogicalKeyboardKey.escape), () => fired++), KeyEventResult.ignored);
expect(gate.handle(_repeat(LogicalKeyboardKey.escape), () => fired++), KeyEventResult.ignored);
expect(fired, 0);
});
test('keyDown phase acts on KeyDown and swallows the release silently', () {
final gate = BackPressGate();
var fired = 0;
void onBack() => fired++;
expect(gate.handle(_down(LogicalKeyboardKey.escape), onBack, phase: BackPhase.keyDown), KeyEventResult.handled);
expect(fired, 1);
expect(gate.handle(_up(LogicalKeyboardKey.escape), onBack, phase: BackPhase.keyDown), KeyEventResult.handled);
expect(fired, 1);
});
test('a KeyDown while still armed is a fresh press (the previous release was swallowed off-app)', () {
final gate = BackPressGate();
var fired = 0;
void onBack() => fired++;
gate.handle(_down(LogicalKeyboardKey.goBack), onBack);
// No KeyUp: a closing TV IME ate it.
gate.handle(_down(LogicalKeyboardKey.goBack), onBack);
gate.handle(_up(LogicalKeyboardKey.goBack), onBack);
expect(fired, 1);
});
test('reset forgets an in-flight press so its release is ignored', () {
final gate = BackPressGate();
var fired = 0;
gate.handle(_down(LogicalKeyboardKey.goBack), () => fired++);
gate.reset();
expect(gate.handle(_up(LogicalKeyboardKey.goBack), () => fired++), KeyEventResult.ignored);
expect(fired, 0);
});
test('non-back keys are never consumed', () {
final gate = BackPressGate();
expect(gate.handle(_down(LogicalKeyboardKey.arrowLeft), () {}), KeyEventResult.ignored);
expect(gate.handle(_up(LogicalKeyboardKey.enter), () {}), KeyEventResult.ignored);
});
test('backPhaseFor is keyDown on Apple TV and keyUp elsewhere', () {
expect(backPhaseFor(_down(LogicalKeyboardKey.escape)), BackPhase.keyUp);
TvDetectionService.debugSetAppleTVOverride(true);
expect(backPhaseFor(_down(LogicalKeyboardKey.escape)), BackPhase.keyDown);
});
});
group('BackKeyOwner', () {
testWidgets('consumes Back for its subtree and lets other keys through', (tester) async {
var backs = 0;
final outer = <LogicalKeyboardKey>[];
await tester.pumpWidget(
Focus(
onKeyEvent: (_, event) {
if (event is KeyDownEvent) outer.add(event.logicalKey);
return KeyEventResult.ignored;
},
child: BackKeyOwner(
onBack: () => backs++,
child: const Focus(autofocus: true, child: SizedBox.expand()),
),
),
);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
expect(backs, 1);
expect(outer, [LogicalKeyboardKey.arrowDown]);
});
});
}
+4 -9
View File
@@ -3,7 +3,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/key_event_utils.dart';
void main() {
testWidgets('unwired single-line fields traverse with arrow keys', (tester) async {
@@ -94,11 +93,10 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'multiline');
});
testWidgets('back on a field with onBack fires once on key up and marks the frame handled', (tester) async {
// The field must route back through the shared handler like every other
// focusable: consume KeyDown, run onBack on KeyUp, and mark the frame so
// a parallel back dispatch (the layer focus lands on next) dedupes.
addTearDown(BackKeyCoordinator.clear);
testWidgets('back on a field with onBack fires once on key up', (tester) async {
// Outside a TV IME the field acts on KeyUp like every other Back owner:
// KeyDown is consumed (so a route pop cannot double-run on KeyUp) and
// onBack runs once when the press ends.
final node = FocusNode(debugLabel: 'field');
addTearDown(node.dispose);
final controller = TextEditingController();
@@ -133,9 +131,6 @@ void main() {
await tester.sendKeyUpEvent(LogicalKeyboardKey.escape);
expect(backs, 1);
// Same-frame dedupe mark: assert before pumping, which runs the
// coordinator's post-frame clear.
expect(BackKeyCoordinator.consumeIfHandled(), isTrue);
await tester.pump();
});
}
-94
View File
@@ -1,80 +1,10 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/utils/platform_detector.dart';
void main() {
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyUpSuppressor.clearSuppression();
BackKeyCoordinator.clear();
});
testWidgets('tvOS physical keyboard back runs on key down and suppresses key up', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
var backs = 0;
final downResult = handleBackKeyAction(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.keyboard,
),
() => backs++,
);
final upResult = handleBackKeyAction(
const KeyUpEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.keyboard,
),
() => backs++,
);
expect(downResult, KeyEventResult.handled);
expect(upResult, KeyEventResult.handled);
expect(backs, 1);
await tester.pump();
});
testWidgets('tvOS remote back runs on key down for non-keyboard device types', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
var backs = 0;
final downResult = handleBackKeyAction(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.directionalPad,
),
() => backs++,
);
final upResult = handleBackKeyAction(
const KeyUpEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
timeStamp: Duration.zero,
deviceType: ui.KeyEventDeviceType.directionalPad,
),
() => backs++,
);
expect(downResult, KeyEventResult.handled);
expect(upResult, KeyEventResult.handled);
expect(backs, 1);
await tester.pump();
});
test('one-shot select consumes every phase and activates only on key down', () {
var activations = 0;
const down = KeyDownEvent(
@@ -104,30 +34,6 @@ void main() {
expect(handleOneShotSelect(unrelated, () => activations++), KeyEventResult.ignored);
expect(activations, 1);
});
group('BackKeyCoordinator', () {
testWidgets('suppresses one parallel back dispatch in the current frame', (tester) async {
BackKeyCoordinator.markHandled();
expect(BackKeyCoordinator.consumeIfHandled(), isTrue);
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
await tester.pump();
});
testWidgets('does not suppress an independent system back in a later frame', (tester) async {
BackKeyCoordinator.markHandled();
await tester.pump();
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
});
testWidgets('clear discards a pending duplicate marker', (tester) async {
BackKeyCoordinator.markHandled();
BackKeyCoordinator.clear();
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
await tester.pump();
});
});
group('dpadKeyHandler trapHorizontalEdges', () {
testWidgets('consumes edge LEFT/RIGHT so focus cannot escape the group', (tester) async {
+326
View File
@@ -0,0 +1,326 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/back_press.dart';
import 'package:plezy/focus/navigator_back_handler.dart';
import 'package:plezy/utils/platform_detector.dart';
/// Root navigator wrapped the way `main.dart` wraps it. [home] is the root
/// route; a [nested] navigator (wrapped the way the profile session wraps it)
/// can be mounted inside it.
class _Harness extends StatelessWidget {
const _Harness({required this.rootKey, required this.home});
final GlobalKey<NavigatorState> rootKey;
final Widget home;
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: rootKey,
home: home,
builder: (_, child) => NavigatorBackHandler(navigatorKey: rootKey, child: child!),
);
}
}
class _Nested extends StatelessWidget {
const _Nested({required this.nestedKey, required this.home});
final GlobalKey<NavigatorState> nestedKey;
final Widget home;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
nestedKey.currentState?.maybePop();
},
child: NavigatorBackHandler(
navigatorKey: nestedKey,
child: Navigator(
key: nestedKey,
onGenerateRoute: (_) => MaterialPageRoute<void>(builder: (_) => home),
),
),
);
}
}
/// A screen whose back policy lives in its PopScope, like every migrated screen.
class _Screen extends StatelessWidget {
const _Screen({required this.label, this.canPop = true, this.onBack});
final String label;
final bool canPop;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: canPop,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
onBack?.call();
},
child: Focus(autofocus: true, debugLabel: label, child: Text(label)),
);
}
}
Future<void> _press(WidgetTester tester, [LogicalKeyboardKey key = LogicalKeyboardKey.escape]) async {
await tester.sendKeyDownEvent(key);
await tester.sendKeyUpEvent(key);
await tester.pump();
await tester.pump();
}
void main() {
setUp(() => TvDetectionService.debugSetAppleTVOverride(false));
tearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
testWidgets('a Back key on a pushed route pops it, once', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: const _Screen(label: 'home'),
),
);
rootKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => const _Screen(label: 'detail')));
await tester.pumpAndSettle();
expect(find.text('detail'), findsOneWidget);
await _press(tester);
await tester.pumpAndSettle();
expect(find.text('detail'), findsNothing);
expect(find.text('home'), findsOneWidget);
});
testWidgets('a Back key reaches the route PopScope when canPop is false', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
var backs = 0;
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: _Screen(label: 'home', canPop: false, onBack: () => backs++),
),
);
await tester.pump();
await _press(tester);
expect(backs, 1);
// Repeat events of a held press never re-fire.
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
await tester.sendKeyRepeatEvent(LogicalKeyboardKey.escape);
await tester.sendKeyRepeatEvent(LogicalKeyboardKey.escape);
await tester.sendKeyUpEvent(LogicalKeyboardKey.escape);
await tester.pump();
await tester.pump();
expect(backs, 2);
});
testWidgets('a Back press racing a push is swallowed instead of popping the new route', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
var homeBacks = 0;
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: _Screen(label: 'home', canPop: false, onBack: () => homeBacks++),
),
);
await tester.pump();
// Focus is still on the covered route until the new route's autofocus
// lands in a later microtask/frame.
rootKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => const _Screen(label: 'detail')));
expect(await simulateKeyDownEvent(LogicalKeyboardKey.escape), isTrue);
expect(await simulateKeyUpEvent(LogicalKeyboardKey.escape), isTrue);
await tester.pumpAndSettle();
expect(find.text('detail'), findsOneWidget);
expect(homeBacks, 0);
});
testWidgets('a Back key at the true root with nothing to pop is left to the platform', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: const _Screen(label: 'home'),
),
);
await tester.pump();
// Unhandled: the framework reports false, so the Android embedding may
// redispatch it into onBackPressed and exit the app.
expect(await simulateKeyDownEvent(LogicalKeyboardKey.escape), isFalse);
expect(await simulateKeyUpEvent(LogicalKeyboardKey.escape), isFalse);
});
testWidgets('a focused owner consumes Back before the route', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
var owned = 0;
var popped = 0;
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) => popped++,
child: BackKeyOwner(
onBack: () => owned++,
child: const Focus(autofocus: true, child: SizedBox.expand()),
),
),
),
);
await tester.pump();
await _press(tester);
expect(owned, 1);
expect(popped, 0);
});
testWidgets('the KeyUp of a KeyDown-fired pop lands on the uncovered route and does nothing', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final rootKey = GlobalKey<NavigatorState>();
var homeBacks = 0;
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: _Screen(label: 'home', canPop: false, onBack: () => homeBacks++),
),
);
rootKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => const _Screen(label: 'detail')));
await tester.pumpAndSettle();
// Apple TV acts on KeyDown: the detail route is gone before the KeyUp.
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('detail'), findsNothing);
expect(await simulateKeyUpEvent(LogicalKeyboardKey.escape), isTrue, reason: 'orphan KeyUp is swallowed');
await tester.pumpAndSettle();
expect(homeBacks, 0);
// The next full press is a fresh one and does act.
await _press(tester);
expect(homeBacks, 1);
});
testWidgets('an owner that acted on KeyDown and moved focus leaves nothing for the route', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
final target = FocusNode(debugLabel: 'target');
addTearDown(target.dispose);
var popped = 0;
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) => popped++,
child: Column(
children: [
BackKeyOwner(
phase: BackPhase.keyDown,
onBack: target.requestFocus,
child: const Focus(autofocus: true, child: SizedBox(height: 10)),
),
Focus(focusNode: target, child: const SizedBox(height: 10)),
],
),
),
),
);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(target.hasPrimaryFocus, isTrue);
expect(await simulateKeyUpEvent(LogicalKeyboardKey.escape), isTrue, reason: 'orphan KeyUp is swallowed');
await tester.pump();
await tester.pump();
expect(popped, 0);
});
testWidgets('a root dialog over a nested navigator is popped by the root handler only', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
final nestedKey = GlobalKey<NavigatorState>();
var nestedBacks = 0;
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: _Nested(
nestedKey: nestedKey,
home: _Screen(label: 'main', canPop: false, onBack: () => nestedBacks++),
),
),
);
await tester.pumpAndSettle();
unawaited(
showDialog<void>(
context: rootKey.currentContext!,
builder: (_) => const AlertDialog(content: Focus(autofocus: true, child: Text('dialog'))),
),
);
await tester.pumpAndSettle();
expect(find.text('dialog'), findsOneWidget);
await _press(tester);
await tester.pumpAndSettle();
expect(find.text('dialog'), findsNothing);
expect(nestedBacks, 0);
});
testWidgets('Back inside the nested navigator reaches the nested route policy', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
final nestedKey = GlobalKey<NavigatorState>();
var nestedBacks = 0;
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: _Nested(
nestedKey: nestedKey,
home: _Screen(label: 'main', canPop: false, onBack: () => nestedBacks++),
),
),
);
await tester.pumpAndSettle();
await _press(tester);
expect(nestedBacks, 1);
// A pushed nested route pops on Back; the root navigator is untouched.
nestedKey.currentState!.push(MaterialPageRoute<void>(builder: (_) => const _Screen(label: 'nested detail')));
await tester.pumpAndSettle();
await _press(tester);
await tester.pumpAndSettle();
expect(find.text('nested detail'), findsNothing);
expect(find.text('main'), findsOneWidget);
expect(nestedBacks, 1);
});
testWidgets('gameButtonB and goBack are Back keys too', (tester) async {
final rootKey = GlobalKey<NavigatorState>();
var backs = 0;
await tester.pumpWidget(
_Harness(
rootKey: rootKey,
home: _Screen(label: 'home', canPop: false, onBack: () => backs++),
),
);
await tester.pump();
await _press(tester, LogicalKeyboardKey.gameButtonB);
expect(backs, 1);
});
}
@@ -8,6 +8,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/focus/navigator_back_handler.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_rating.dart';
@@ -266,6 +267,7 @@ Future<void> _pumpDetail(
addTearDown(serverManager.dispose);
addTearDown(multiServer.dispose);
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
@@ -274,6 +276,8 @@ Future<void> _pumpDetail(
ChangeNotifierProvider<CatalogSourcesProvider>.value(value: sources),
],
child: MaterialApp(
navigatorKey: navigatorKey,
builder: (_, child) => NavigatorBackHandler(navigatorKey: navigatorKey, child: child!),
theme: monoTheme(dark: true),
home: pushedRoute
? Builder(
@@ -1372,11 +1376,10 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Hosted request sheet'), findsOneWidget);
// Apple TV acts on KeyDown; the KeyUp is delivered after the sheet has
// closed and must not become a route pop.
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
// Android TV can dispatch route Back for the same remote press. Deliver
// that duplicate in the same key sequence, before the coordinator's
// one-frame ownership marker is cleared.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/focus/navigator_back_handler.dart';
import 'package:plezy/mixins/grid_focus_node_mixin.dart';
import 'package:plezy/screens/focusable_detail_screen_mixin.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -14,12 +14,10 @@ void main() {
setUp(() {
TvDetectionService.debugSetAppleTVOverride(false);
BackKeyCoordinator.clear();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyCoordinator.clear();
});
testWidgets('detail scaffold scrolls to top on iOS top safe-area tap', (tester) async {
@@ -104,12 +102,10 @@ void main() {
expect(sheet, findsOneWidget);
expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget);
// Apple TV acts on KeyDown; the KeyUp is delivered after the sheet has
// closed and must not become a route pop.
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
// Android TV can dispatch route Back for the same remote press. Dispatch
// that duplicate before pumping so the coordinator deterministically
// associates it with this key-down instead of a later, independent Back.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
@@ -143,8 +139,11 @@ void main() {
}
Future<void> _pushDetailSurface(WidgetTester tester, {required String surfaceName, required bool hasActions}) async {
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
builder: (_, child) => NavigatorBackHandler(navigatorKey: navigatorKey, child: child!),
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.android),
home: Scaffold(
body: Builder(
@@ -3,8 +3,7 @@ import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/focus/back_press.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/library_first_character.dart';
import 'package:plezy/screens/libraries/alpha_jump_bar.dart';
@@ -50,8 +49,6 @@ void main() {
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyUpSuppressor.clearSuppression();
BackKeyCoordinator.clear();
});
for (final backKey in _backKeys) {
@@ -122,11 +119,11 @@ void main() {
await tester.pump();
expect(harness.nextFocusNode.hasFocus, isTrue);
final movingRepeatHandled = _sendKeyRepeat(tester, backKey);
final movingUpHandled = _sendKeyUp(tester, backKey);
// The rest of the press lands on the new focus chain. The ancestor's
// gate never saw the KeyDown, so it must not run a second back.
_sendKeyRepeat(tester, backKey);
_sendKeyUp(tester, backKey);
expect(movingRepeatHandled, isTrue);
expect(movingUpHandled, isTrue);
expect(harness.jumpBarBacks, 2);
expect(harness.ancestorEvents, 2);
expect(harness.ancestorBacks, 0);
@@ -269,6 +266,7 @@ class _BackHarness {
final jumpBarFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back');
final nextFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back_destination');
final ancestorFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back_ancestor');
final ancestorGate = BackPressGate();
var jumpBarBacks = 0;
var ancestorEvents = 0;
@@ -293,7 +291,7 @@ Future<_BackHarness> _pumpBackHarness(WidgetTester tester) async {
focusNode: harness.ancestorFocusNode,
onKeyEvent: (_, event) {
harness.ancestorEvents++;
return handleBackKeyAction(event, () => harness.ancestorBacks++);
return harness.ancestorGate.handle(event, () => harness.ancestorBacks++);
},
child: Column(
children: [
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/focus/navigator_back_handler.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_query.dart';
@@ -52,12 +52,10 @@ void main() {
SettingsService.resetForTesting();
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(false);
BackKeyCoordinator.clear();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyCoordinator.clear();
});
testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async {
@@ -258,10 +256,11 @@ void main() {
expect(sheet, findsOneWidget);
expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
// A route-level Back can accompany the same TV remote press before the
// coordinator's one-frame marker is cleared.
await tester.binding.handlePopRoute();
// Apple TV acts on KeyDown, so the KeyUp arrives after the sheet closed;
// it must not become a route pop.
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(sheet, findsNothing);
@@ -704,6 +703,7 @@ class _PlaylistHarness {
});
Widget wrap(Widget child, {TargetPlatform platform = TargetPlatform.android}) {
final navigatorKey = GlobalKey<NavigatorState>();
return TranslationProvider(
child: MultiProvider(
providers: [
@@ -712,6 +712,8 @@ class _PlaylistHarness {
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
],
child: MaterialApp(
navigatorKey: navigatorKey,
builder: (_, child) => NavigatorBackHandler(navigatorKey: navigatorKey, child: child!),
theme: monoTheme(dark: true).copyWith(platform: platform),
home: child,
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/navigator_back_handler.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/screens/profile/add_local_profile_screen.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -86,10 +87,13 @@ void main() {
});
testWidgets('remote back pops the new profile page', (tester) async {
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: MaterialApp(
navigatorKey: navigatorKey,
builder: (_, child) => NavigatorBackHandler(navigatorKey: navigatorKey, child: child!),
home: Builder(
builder: (context) => Scaffold(
body: Center(
@@ -6,6 +6,7 @@ import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/navigator_back_handler.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
@@ -67,6 +68,7 @@ void main() {
await db.close();
});
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
@@ -79,6 +81,8 @@ void main() {
],
child: InputModeTracker(
child: MaterialApp(
navigatorKey: navigatorKey,
builder: (_, child) => NavigatorBackHandler(navigatorKey: navigatorKey, child: child!),
home: Builder(
builder: (context) => Scaffold(
body: Center(
-3
View File
@@ -7,7 +7,6 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
@@ -832,8 +831,6 @@ void _resetGlobalTestState() {
FocusManager.instance.primaryFocus?.unfocus();
HardwareKeyboard.instance.clearState();
SelectKeyUpSuppressor.clearSuppression();
BackKeyUpSuppressor.clearSuppression();
BackKeyCoordinator.clear();
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false);
SettingsService.resetForTesting();
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/back_press.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/providers/playback_state_provider.dart';
@@ -48,6 +49,7 @@ class _PlayerShell extends StatefulWidget {
class _PlayerShellState extends State<_PlayerShell> {
final FocusNode _screenFocusNode = FocusNode(debugLabel: 'VideoPlayerScreen');
final GlobalKey _overlayChildKey = GlobalKey();
final BackPressGate _backGate = BackPressGate();
BuildContext get _sheetContext => _overlayChildKey.currentContext ?? context;
@@ -80,6 +82,7 @@ class _PlayerShellState extends State<_PlayerShell> {
event,
navigationKey,
() => _handleScreenPlayerNavigation(navigationKey),
backGate: _backGate,
);
}
if (node.hasPrimaryFocus) {
@@ -89,7 +92,7 @@ class _PlayerShellState extends State<_PlayerShell> {
},
child: OverlaySheetHost(
canPop: false,
onSystemBack: () => _handleScreenPlayerNavigation(PlayerNavigationKey.back),
onBack: () => _handleScreenPlayerNavigation(PlayerNavigationKey.back),
child: Builder(
key: _overlayChildKey,
builder: (sheetContext) => Scaffold(
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/back_press.dart';
import 'package:plezy/models/companion_remote/remote_command.dart';
import 'package:plezy/services/companion_remote/companion_remote_receiver.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
@@ -12,6 +13,7 @@ void main() {
testWidgets('Back command dispatches semantic gamepad B events', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
final backGate = BackPressGate();
final events = <KeyEvent>[];
var actions = 0;
var exits = 0;
@@ -37,7 +39,7 @@ void main() {
return handlePlayerNavigationKeyAction(event, navigationKey, () {
actions++;
coordinator.handle(navigationKey);
});
}, backGate: backGate);
},
child: const SizedBox.expand(),
),
+43 -7
View File
@@ -99,7 +99,7 @@ void main() {
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.gamepad));
});
testWidgets('simulateKeyUp returns to the key-down focus when focus changes', (tester) async {
testWidgets('simulateKeyUp is delivered to the current focus, like a hardware release', (tester) async {
final firstNode = FocusNode(debugLabel: 'first');
final secondNode = FocusNode(debugLabel: 'second');
addTearDown(firstNode.dispose);
@@ -139,6 +139,7 @@ void main() {
await tester.pump();
expect(firstEvents, hasLength(1));
expect(firstEvents.single, isA<KeyDownEvent>());
expect(HardwareKeyboard.instance.logicalKeysPressed, contains(LogicalKeyboardKey.enter));
secondNode.requestFocus();
await tester.pump();
@@ -147,9 +148,41 @@ void main() {
simulateKeyUp(LogicalKeyboardKey.enter);
await tester.pump();
expect(firstEvents, hasLength(2));
expect(firstEvents.last, isA<KeyUpEvent>());
expect(secondEvents, isEmpty);
expect(firstEvents, hasLength(1));
expect(secondEvents.single, isA<KeyUpEvent>());
expect(HardwareKeyboard.instance.logicalKeysPressed, isNot(contains(LogicalKeyboardKey.enter)));
});
testWidgets('synthetic keys reach HardwareKeyboard handlers and regularize against pressed state', (tester) async {
final events = await _pumpKeyEventRecorder(tester);
final hardwareEvents = <KeyEvent>[];
bool observe(KeyEvent event) {
hardwareEvents.add(event);
return false;
}
HardwareKeyboard.instance.addHandler(observe);
addTearDown(() => HardwareKeyboard.instance.removeHandler(observe));
// A hardware press is already holding the key.
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
hardwareEvents.clear();
events.clear();
simulateKeyDown(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(hardwareEvents.single, isA<KeyRepeatEvent>());
expect(events.single, isA<KeyRepeatEvent>());
simulateKeyUp(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(hardwareEvents.last, isA<KeyUpEvent>());
expect(HardwareKeyboard.instance.logicalKeysPressed, isNot(contains(LogicalKeyboardKey.arrowLeft)));
// A release for a key nobody holds is dropped rather than desynchronizing state.
simulateKeyUp(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(hardwareEvents, hasLength(2));
});
testWidgets('simulateKeyPress stops at skipRemainingHandlers', (tester) async {
@@ -191,7 +224,7 @@ void main() {
expect(parentEvents, isEmpty);
});
testWidgets('dispose cancels queued dispatch and repeat timers', (tester) async {
testWidgets('dispose releases held keys and cancels repeat timers', (tester) async {
final events = await _pumpKeyEventRecorder(tester);
final simulator = KeyEventSimulatorController();
@@ -208,8 +241,11 @@ void main() {
simulator.simulateKeyUp(LogicalKeyboardKey.enter);
await tester.pump(const Duration(seconds: 1));
expect(events, hasLength(1));
expect(events.single, isA<KeyDownEvent>());
expect(events, hasLength(2));
expect(events.first, isA<KeyDownEvent>());
expect(events.last, isA<KeyUpEvent>());
expect(events.last.logicalKey, LogicalKeyboardKey.enter);
expect(HardwareKeyboard.instance.logicalKeysPressed, isEmpty);
});
}
+15 -24
View File
@@ -4,9 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/back_press.dart';
import 'package:plezy/focus/focusable_text_field.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/services/gamepad_service.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -15,11 +14,6 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.setForceTVSync(false);
GamepadService.debugNativeTextInputFocusHandler = null;
// TV back tests arm the suppressor on KeyDown; a test ending mid-press
// would leak the armed state into the next test (flutter_test clears the
// hardware observer that would otherwise end it with the press).
BackKeyUpSuppressor.clearSuppression();
BackKeyCoordinator.clear();
});
testWidgets('tab traversal focuses the text form field', (tester) async {
@@ -1063,8 +1057,7 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
addTearDown(BackKeyCoordinator.clear);
addTearDown(BackKeyUpSuppressor.clearSuppression);
final sidebarGate = BackPressGate();
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'search_field');
final sidebarFocusNode = FocusNode(debugLabel: 'sidebar');
@@ -1089,11 +1082,11 @@ void main() {
sidebarFocusNode.requestFocus();
},
),
// The destination focus chain runs the shared handler's KeyUp
// semantics — the SearchScreen sidebar shape.
// The destination focus chain is a Back owner with its own gate
// — the SearchScreen sidebar shape.
Focus(
focusNode: sidebarFocusNode,
onKeyEvent: (node, event) => handleBackKeyAction(event, () => sidebarBacks++),
onKeyEvent: (node, event) => sidebarGate.handle(event, () => sidebarBacks++),
child: const SizedBox(width: 40, height: 40),
),
],
@@ -1111,8 +1104,8 @@ void main() {
expect(fieldBacks, 1);
expect(sidebarFocusNode.hasPrimaryFocus, isTrue);
// The matching KeyUp is delivered to the NEW focus chain and must be
// swallowed there — one press, one action.
// The matching KeyUp is delivered to the NEW focus chain, whose gate never
// saw the KeyDown and must not act on it — one press, one action.
await tester.sendKeyUpEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(sidebarBacks, 0, reason: 'the KeyUp of a press the field already handled must not run a second back');
@@ -1125,12 +1118,10 @@ void main() {
expect(fieldBacks, 1);
});
testWidgets('Android TV stale back suppression never swallows the next press', (tester) async {
testWidgets('Android TV stale armed back press never swallows the next press', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
addTearDown(BackKeyCoordinator.clear);
addTearDown(BackKeyUpSuppressor.clearSuppression);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'search_field');
final buttonFocusNode = FocusNode(debugLabel: 'plain_button');
@@ -1154,8 +1145,8 @@ void main() {
buttonFocusNode.requestFocus();
},
),
// No suppressor-aware handler here, and the IME swallows the
// press's KeyUp entirely: nothing ever ends the armed state.
// No Back owner here, and the IME swallows the press's KeyUp
// entirely: nothing ever ends the field's armed press.
FilledButton(focusNode: buttonFocusNode, onPressed: () {}, child: const Text('Plain')),
],
),
@@ -1166,19 +1157,19 @@ void main() {
fieldFocusNode.requestFocus();
await tester.pump();
// TV back acts on KeyDown and arms the suppressor for the matching
// KeyUp — which never arrives (the closing IME session ate it).
// TV back acts on KeyDown; the matching KeyUp never arrives (the closing
// IME session ate it), so the field's press stays armed.
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(fieldBacks, 1);
// The stale armed suppressor must clear itself on the next KeyDown
// without consuming it — the second press still runs onBack.
// A fresh KeyDown is a new press and must run onBack again; the stale
// armed state must never swallow it.
fieldFocusNode.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(fieldBacks, 2, reason: 'a stray armed suppressor must never swallow a fresh press');
expect(fieldBacks, 2, reason: 'a stale armed press must never swallow a fresh press');
await tester.sendKeyUpEvent(LogicalKeyboardKey.escape);
await tester.pump();
});
+31 -57
View File
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/focus/navigator_back_handler.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/bottom_sheet_header.dart';
import 'package:plezy/widgets/bottom_sheet_page_scaffold.dart';
@@ -660,13 +660,17 @@ void main() {
expect(find.text('Existing root page'), findsNothing);
});
group('opt-in canPop / onSystemBack', () {
group('opt-in canPop / onBack', () {
// Pushes an OverlaySheetHost route on top of a home route so we can observe
// whether a simulated system back pops the route. The host's child has an
// "Open" button that shows a sheet containing "SHEET".
Future<void> pushHost(WidgetTester tester, {required bool? canPop, VoidCallback? onSystemBack}) async {
// whether a back — system, or a key press routed by NavigatorBackHandler —
// pops the route. The host's child has an "Open" button that shows a sheet
// containing "SHEET".
Future<void> pushHost(WidgetTester tester, {required bool? canPop, VoidCallback? onBack}) async {
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
builder: (_, child) => NavigatorBackHandler(navigatorKey: navigatorKey, child: child!),
theme: ThemeData(platform: TargetPlatform.android),
home: Scaffold(
body: Builder(
@@ -676,7 +680,7 @@ void main() {
MaterialPageRoute<void>(
builder: (_) => OverlaySheetHost(
canPop: canPop,
onSystemBack: onSystemBack,
onBack: onBack,
child: Scaffold(
body: Builder(
builder: (sheetContext) => Center(
@@ -719,18 +723,18 @@ void main() {
expect(find.text('Open'), findsNothing, reason: 'route popped');
});
testWidgets('canPop false blocks the pop and runs onSystemBack', (tester) async {
testWidgets('canPop false blocks the pop and runs onBack', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await pushHost(tester, canPop: false, onBack: () => backs++);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Open'), findsOneWidget, reason: 'route not popped');
expect(backs, 1);
});
testWidgets('system back closes an open sheet instead of popping or running onSystemBack', (tester) async {
testWidgets('system back closes an open sheet instead of popping or running onBack', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await pushHost(tester, canPop: false, onBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsOneWidget);
@@ -740,69 +744,39 @@ void main() {
expect(find.text('SHEET'), findsNothing, reason: 'sheet closed');
expect(find.text('Open'), findsOneWidget, reason: 'screen not popped');
expect(backs, 0, reason: 'onSystemBack not called while a sheet was open');
expect(backs, 0, reason: 'onBack not called while a sheet was open');
});
testWidgets('system back does not duplicate a handled TV key Back on an open sheet', (tester) async {
// The dedup is TV-only, so the override has to be on for this to be the
// TV contract rather than an accidental cross-platform assertion.
testWidgets('one TV key Back closes an open sheet and the next reaches onBack through the navigator', (
tester,
) async {
// Apple TV acts on KeyDown, so the press's KeyUp is delivered after the
// sheet has closed — to the screen and then NavigatorBackHandler, which
// must not turn it into a second back.
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await pushHost(tester, canPop: false, onBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsOneWidget);
expect(backs, 0);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
});
testWidgets('touch system back closes the sheet despite an unrelated handled mark', (tester) async {
// Regression: the handled marker is global, and touch platforms never
// deliver Back to the sheet's key handler, so there is nothing here to
// dedup against. Consuming a mark left by any other handler stranded the
// sheet open with no way to dismiss it.
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing, reason: 'stale mark must not swallow the close');
expect(find.text('SHEET'), findsNothing, reason: 'sheet closed by the key press');
expect(find.text('Open'), findsOneWidget, reason: 'screen not popped');
expect(backs, 0);
});
expect(backs, 0, reason: 'the orphaned KeyUp must not run the screen back policy');
testWidgets('system back in a later frame is not mistaken for a duplicate TV key', (tester) async {
// TV-only: the dedup this exercises is skipped on touch platforms, so
// without the override the pop would never consult the marker and the
// post-frame expiry would go untested.
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
expect(find.text('Open'), findsOneWidget);
expect(backs, 0);
expect(backs, 1, reason: 'with no sheet open the key back reaches onBack via Navigator.maybePop');
expect(find.text('Open'), findsOneWidget, reason: 'canPop false keeps the route');
});
});
}
@@ -425,7 +425,7 @@ void main() {
});
// The button's own 7s auto-dismiss is armed for every prompt while
// auto-skip is off, which is the default. handleBackKeyAction acts on the
// auto-skip is off, which is the default. The Back gate acts on the
// key-up, so a timer landing mid-press used to flip the gate and hand the
// press to the screen -- exiting the player on the very press meant to
// keep it open.
+20 -4
View File
@@ -8,7 +8,7 @@ import 'package:intl/date_symbol_data_local.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/focus/back_press.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/media/media_version.dart';
@@ -430,17 +430,20 @@ void main() {
group('handlePlayerNavigationKeyAction', () {
testWidgets('semantic Back activates once on key up', (tester) async {
final backGate = BackPressGate();
var actions = 0;
final downResult = handlePlayerNavigationKeyAction(
_keyDown(LogicalKeyboardKey.gameButtonB),
PlayerNavigationKey.back,
() => actions++,
backGate: backGate,
);
final upResult = handlePlayerNavigationKeyAction(
_keyUp(LogicalKeyboardKey.gameButtonB),
PlayerNavigationKey.back,
() => actions++,
backGate: backGate,
);
expect(downResult, KeyEventResult.handled);
@@ -450,15 +453,22 @@ void main() {
});
testWidgets('Backspace alias activates once on key up', (tester) async {
final backGate = BackPressGate();
var actions = 0;
handlePlayerNavigationKeyAction(
_keyDown(LogicalKeyboardKey.backspace),
PlayerNavigationKey.back,
() => actions++,
backGate: backGate,
);
expect(actions, 0, reason: 'the press is consumed on key down and acts on key up');
handlePlayerNavigationKeyAction(
_keyUp(LogicalKeyboardKey.backspace),
PlayerNavigationKey.back,
() => actions++,
backGate: backGate,
);
expect(BackKeyCoordinator.consumeIfHandled(), isTrue, reason: 'parallel route pop is suppressed on key down');
handlePlayerNavigationKeyAction(_keyUp(LogicalKeyboardKey.backspace), PlayerNavigationKey.back, () => actions++);
expect(actions, 1);
await tester.pump();
@@ -592,13 +602,19 @@ void main() {
}
Future<void> pumpNavigationFocus(WidgetTester tester, PlayerNavigationCoordinator coordinator) async {
final backGate = BackPressGate();
await tester.pumpWidget(
MaterialApp(
home: Focus(
autofocus: true,
onKeyEvent: (_, event) {
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: false);
return handlePlayerNavigationKeyAction(event, navigationKey, () => coordinator.handle(navigationKey));
return handlePlayerNavigationKeyAction(
event,
navigationKey,
() => coordinator.handle(navigationKey),
backGate: backGate,
);
},
child: const SizedBox.expand(),
),