Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703df6a561 | ||
|
|
111af97a61 |
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
)!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user