fix(tvos): make remote input lifecycle engine-owned
This commit is contained in:
@@ -3,7 +3,6 @@ import '../media/ids.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard, LogicalKeyboardKey;
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import '../widgets/server_activities_button.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -15,7 +14,6 @@ import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import 'package:cached_network_image_ce/cached_network_image.dart';
|
||||
|
||||
import '../services/apple_tv_remote_touch_service.dart';
|
||||
import '../services/image_cache_service.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_item_types.dart';
|
||||
@@ -250,7 +248,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
void _focusTvBrowseRailWhenReady({bool immediate = false}) {
|
||||
if (!PlatformDetector.isTV()) return;
|
||||
final suppressSelectUntilKeyUp = _isSelectKeyPressed;
|
||||
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) {
|
||||
_pendingTvBrowseRailFocus = false;
|
||||
return;
|
||||
@@ -262,7 +259,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (rail != null) {
|
||||
_pendingTvBrowseRailFocus = false;
|
||||
rail.requestFocus();
|
||||
if (suppressSelectUntilKeyUp) rail.suppressSelectUntilKeyUp();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -278,21 +274,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (rail == null) return;
|
||||
_pendingTvBrowseRailFocus = false;
|
||||
rail.requestFocus();
|
||||
if (suppressSelectUntilKeyUp) rail.suppressSelectUntilKeyUp();
|
||||
});
|
||||
}
|
||||
|
||||
bool get _isSelectKeyPressed {
|
||||
return HardwareKeyboard.instance.logicalKeysPressed.any(
|
||||
(key) =>
|
||||
key == LogicalKeyboardKey.enter ||
|
||||
key.keyId == 0x0d ||
|
||||
key == LogicalKeyboardKey.numpadEnter ||
|
||||
key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.gameButtonA,
|
||||
);
|
||||
}
|
||||
|
||||
void _applyPendingTvBrowseRailFocus() {
|
||||
if (_pendingTvBrowseRailFocus) _focusTvBrowseRailWhenReady();
|
||||
}
|
||||
@@ -1164,9 +1148,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
onNavigateUp: _focusTopActions,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||
selectSuppressionGestureSignal: PlatformDetector.isAppleTV()
|
||||
? AppleTvRemoteTouchService.instance.touchActiveListenable
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,33 @@ bool shouldPassTvosMenuToSystem({
|
||||
isCurrentTabRoot;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
class TvosMenuPolicyPublisher {
|
||||
TvosMenuPolicyPublisher(this._compute, this._publish);
|
||||
|
||||
final ValueGetter<bool> _compute;
|
||||
final ValueChanged<bool> _publish;
|
||||
int _transactionDepth = 0;
|
||||
|
||||
void run(VoidCallback transaction) {
|
||||
_transactionDepth++;
|
||||
try {
|
||||
transaction();
|
||||
} finally {
|
||||
_transactionDepth--;
|
||||
if (_transactionDepth == 0) {
|
||||
_publish(_compute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void update() {
|
||||
if (_transactionDepth == 0) {
|
||||
_publish(_compute());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
enum ProfileInvalidationAction { none, invalidateNow }
|
||||
|
||||
@@ -252,6 +279,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
bool _isSidebarFocused = false;
|
||||
bool _isSidebarInteractionExpanded = false;
|
||||
bool _isOverlaySheetOpen = false;
|
||||
late final TvosMenuPolicyPublisher _tvosMenuPolicyPublisher;
|
||||
|
||||
/// The binder is now owned by a top-level [Provider] (see main.dart) so
|
||||
/// the splash can await its first settle before navigating here. We just
|
||||
@@ -298,6 +326,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tvosMenuPolicyPublisher = TvosMenuPolicyPublisher(() => _shouldPassTvosMenuToSystem, _setTvosMenuPassthrough);
|
||||
_isOffline = widget.isOfflineMode;
|
||||
_offlineUntilConnected = widget.isOfflineMode;
|
||||
|
||||
@@ -1213,9 +1242,13 @@ class _MainScreenState extends State<MainScreen>
|
||||
unawaited(TvosSystemNavigationService.setMenuPassthroughEnabled(enabled));
|
||||
}
|
||||
|
||||
void _runNavigationTransaction(VoidCallback transaction) {
|
||||
_tvosMenuPolicyPublisher.run(transaction);
|
||||
}
|
||||
|
||||
void _updateTvosMenuPassthrough() {
|
||||
if (!mounted) return;
|
||||
_setTvosMenuPassthrough(_shouldPassTvosMenuToSystem);
|
||||
_tvosMenuPolicyPublisher.update();
|
||||
}
|
||||
|
||||
/// Suppress stray back events after a child route pops.
|
||||
@@ -1553,8 +1586,10 @@ class _MainScreenState extends State<MainScreen>
|
||||
|
||||
void _openSettings() {
|
||||
if (PlatformDetector.shouldUseSideNavigation(context)) {
|
||||
_selectTab(NavigationTabId.settings);
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
_runNavigationTransaction(() {
|
||||
_selectTab(NavigationTabId.settings);
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1816,13 +1851,17 @@ class _MainScreenState extends State<MainScreen>
|
||||
isReconnecting: _isReconnecting,
|
||||
onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged,
|
||||
onDestinationSelected: (tab) {
|
||||
final restorePreviousFocus = tab == _currentTab;
|
||||
_selectTab(tab);
|
||||
_focusContent(restorePreviousFocus: restorePreviousFocus);
|
||||
_runNavigationTransaction(() {
|
||||
final restorePreviousFocus = tab == _currentTab;
|
||||
_selectTab(tab);
|
||||
_focusContent(restorePreviousFocus: restorePreviousFocus);
|
||||
});
|
||||
},
|
||||
onLibrarySelected: (key) {
|
||||
_selectLibrary(key);
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
_runNavigationTransaction(() {
|
||||
_selectLibrary(key);
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
});
|
||||
},
|
||||
onNavigateToContent: _focusContent,
|
||||
onReconnect: _triggerReconnect,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -24,14 +23,11 @@ class AppleTvRemoteTouchService {
|
||||
static const double defaultSwipeThreshold = 180;
|
||||
static const double defaultAxisSwitchDominanceRatio = 1.5;
|
||||
static const Duration defaultSwipeRepeatInterval = Duration(milliseconds: 140);
|
||||
static const Duration defaultClickAfterDirectionSuppression = Duration(milliseconds: 220);
|
||||
|
||||
static final AppleTvRemoteTouchService instance = AppleTvRemoteTouchService();
|
||||
|
||||
final BasicMessageChannel<dynamic> _channel;
|
||||
final void Function(LogicalKeyboardKey logicalKey) _simulateKeyPress;
|
||||
final void Function(LogicalKeyboardKey logicalKey) _simulateKeyDown;
|
||||
final void Function(LogicalKeyboardKey logicalKey) _simulateKeyUp;
|
||||
final VoidCallback _scheduleFrame;
|
||||
final DateTime Function() _now;
|
||||
final GamepadDuplicateInputGuard _duplicateInputGuard;
|
||||
@@ -40,31 +36,20 @@ class AppleTvRemoteTouchService {
|
||||
final double swipeThreshold;
|
||||
final double axisSwitchDominanceRatio;
|
||||
final Duration swipeRepeatInterval;
|
||||
final Duration clickAfterDirectionSuppression;
|
||||
|
||||
bool _listening = false;
|
||||
bool _nativeKeyHandlerRegistered = false;
|
||||
bool _touchActive = false;
|
||||
final ValueNotifier<bool> _touchActiveNotifier = ValueNotifier<bool>(false);
|
||||
double _startX = 0;
|
||||
double _startY = 0;
|
||||
double _anchorX = 0;
|
||||
double _anchorY = 0;
|
||||
_SwipeAxis? _lastSwipeAxis;
|
||||
DateTime? _lastSwipeAt;
|
||||
DateTime? _lastDirectionalInputAt;
|
||||
DateTime? _lastSyntheticSelectAt;
|
||||
DateTime? _lastAcceptedNativeSelectDownAt;
|
||||
DateTime? _lastAcceptedNativeSelectUpAt;
|
||||
int _suppressedNativeSelectDowns = 0;
|
||||
bool _nativeSelectPressed = false;
|
||||
bool _selectPressedFromClick = false;
|
||||
|
||||
AppleTvRemoteTouchService({
|
||||
BasicMessageChannel<dynamic>? channel,
|
||||
void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress,
|
||||
void Function(LogicalKeyboardKey logicalKey)? simulateKeyDown,
|
||||
void Function(LogicalKeyboardKey logicalKey)? simulateKeyUp,
|
||||
VoidCallback? scheduleFrame,
|
||||
DateTime Function()? now,
|
||||
GamepadDuplicateInputGuard? duplicateInputGuard,
|
||||
@@ -72,12 +57,9 @@ class AppleTvRemoteTouchService {
|
||||
this.swipeThreshold = defaultSwipeThreshold,
|
||||
this.axisSwitchDominanceRatio = defaultAxisSwitchDominanceRatio,
|
||||
this.swipeRepeatInterval = defaultSwipeRepeatInterval,
|
||||
this.clickAfterDirectionSuppression = defaultClickAfterDirectionSuppression,
|
||||
}) : assert(axisSwitchDominanceRatio >= 1),
|
||||
_channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()),
|
||||
_simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress,
|
||||
_simulateKeyDown = simulateKeyDown ?? key_sim.simulateKeyDown,
|
||||
_simulateKeyUp = simulateKeyUp ?? key_sim.simulateKeyUp,
|
||||
_scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle,
|
||||
_now = now ?? DateTime.now,
|
||||
_duplicateInputGuard =
|
||||
@@ -85,14 +67,6 @@ class AppleTvRemoteTouchService {
|
||||
|
||||
Stream<AppleTvRemotePlayPauseAction> get playPauseActions => _playPauseController.stream;
|
||||
|
||||
/// Whether a Siri-remote touch gesture is currently in progress (finger down).
|
||||
/// Cleared when the touch ends or cancels. tvOS-only; `false` elsewhere.
|
||||
bool get isTouchActive => _touchActive;
|
||||
|
||||
/// Listenable mirror of [isTouchActive] so widgets can react when the active
|
||||
/// touch gesture ends (used to extend Home-rail select suppression).
|
||||
ValueListenable<bool> get touchActiveListenable => _touchActiveNotifier;
|
||||
|
||||
void start() {
|
||||
if (_listening) return;
|
||||
_channel.setMessageHandler(handleMessage);
|
||||
@@ -106,8 +80,6 @@ class AppleTvRemoteTouchService {
|
||||
_channel.setMessageHandler(null);
|
||||
_unregisterNativeKeyHandler();
|
||||
_duplicateInputGuard.clear();
|
||||
_resetNativeSelectBurstState();
|
||||
_releaseSelectFromClick(source: 'stop');
|
||||
_resetTouch();
|
||||
_listening = false;
|
||||
}
|
||||
@@ -118,12 +90,6 @@ class AppleTvRemoteTouchService {
|
||||
_log('consume native media key reason=direct-playback-action');
|
||||
return true;
|
||||
}
|
||||
if (_shouldConsumeNativeSelectDuplicate(event)) {
|
||||
return true;
|
||||
}
|
||||
if (event is KeyDownEvent && _isDirectionalKey(event.logicalKey)) {
|
||||
_lastDirectionalInputAt = _now();
|
||||
}
|
||||
return _duplicateInputGuard.handleNativeKeyEvent(event);
|
||||
}
|
||||
|
||||
@@ -159,10 +125,6 @@ class AppleTvRemoteTouchService {
|
||||
_resetTouch();
|
||||
case 'cancelled':
|
||||
_resetTouch();
|
||||
case 'click_e':
|
||||
_releaseSelectFromClick(source: 'click_e');
|
||||
case 'click_s':
|
||||
_pressSelectFromClick();
|
||||
case 'play_pause':
|
||||
final source = arguments['source'] is String ? arguments['source'] as String : 'native';
|
||||
final detail = arguments['detail'] is String ? arguments['detail'] as String : null;
|
||||
@@ -189,7 +151,6 @@ class AppleTvRemoteTouchService {
|
||||
|
||||
void _startTouch(double x, double y) {
|
||||
_touchActive = true;
|
||||
_touchActiveNotifier.value = true;
|
||||
_startX = x;
|
||||
_startY = y;
|
||||
_anchorX = x;
|
||||
@@ -262,147 +223,6 @@ class AppleTvRemoteTouchService {
|
||||
return axis == _SwipeAxis.horizontal ? horizontal : vertical;
|
||||
}
|
||||
|
||||
void _pressSelectFromClick() {
|
||||
final now = _now();
|
||||
final lastDirectionalInputAt = _lastDirectionalInputAt;
|
||||
if (lastDirectionalInputAt != null && now.difference(lastDirectionalInputAt) <= clickAfterDirectionSuppression) {
|
||||
final age = now.difference(lastDirectionalInputAt).inMilliseconds;
|
||||
_log('suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-direction age=${age}ms');
|
||||
return;
|
||||
}
|
||||
|
||||
final lastSyntheticSelectAt = _lastSyntheticSelectAt;
|
||||
if (lastSyntheticSelectAt != null && now.difference(lastSyntheticSelectAt).abs() <= duplicateSuppressionWindow) {
|
||||
final age = now.difference(lastSyntheticSelectAt).abs().inMilliseconds;
|
||||
_log(
|
||||
'suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-synthetic-select age=${age}ms',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_duplicateInputGuard.shouldSuppressSyntheticKey(LogicalKeyboardKey.enter)) {
|
||||
_log('suppress key=${_keyName(LogicalKeyboardKey.enter)} source=click_s reason=recent-native');
|
||||
return;
|
||||
}
|
||||
|
||||
_setTraditionalFocusHighlight();
|
||||
_scheduleFrame();
|
||||
_selectPressedFromClick = true;
|
||||
_log('emit keydown=${_keyName(LogicalKeyboardKey.enter)} source=click_s');
|
||||
_simulateKeyDown(LogicalKeyboardKey.enter);
|
||||
}
|
||||
|
||||
void _releaseSelectFromClick({required String source}) {
|
||||
if (!_selectPressedFromClick) {
|
||||
_log('ignore keyup=${_keyName(LogicalKeyboardKey.enter)} source=$source reason=no-click-select-down');
|
||||
return;
|
||||
}
|
||||
|
||||
_setTraditionalFocusHighlight();
|
||||
_scheduleFrame();
|
||||
_selectPressedFromClick = false;
|
||||
_lastSyntheticSelectAt = _now();
|
||||
_log('emit keyup=${_keyName(LogicalKeyboardKey.enter)} source=$source');
|
||||
_simulateKeyUp(LogicalKeyboardKey.enter);
|
||||
}
|
||||
|
||||
bool _shouldConsumeNativeSelectDuplicate(KeyEvent event) {
|
||||
if (!_isSelectKey(event.logicalKey)) return false;
|
||||
|
||||
final now = _now();
|
||||
if (_selectPressedFromClick) {
|
||||
_log(
|
||||
'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} '
|
||||
'reason=synthetic-select-in-flight',
|
||||
);
|
||||
if (event is KeyUpEvent) {
|
||||
_releaseSelectFromClick(source: 'native_select');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
final lastSyntheticSelectAt = _lastSyntheticSelectAt;
|
||||
if (lastSyntheticSelectAt != null && now.difference(lastSyntheticSelectAt).abs() <= duplicateSuppressionWindow) {
|
||||
final age = now.difference(lastSyntheticSelectAt).abs().inMilliseconds;
|
||||
_log(
|
||||
'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} '
|
||||
'reason=recent-synthetic-select age=${age}ms',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
final lastAcceptedNativeSelectUpAt = _lastAcceptedNativeSelectUpAt;
|
||||
final duplicateCompletedPress =
|
||||
lastAcceptedNativeSelectUpAt != null &&
|
||||
now.difference(lastAcceptedNativeSelectUpAt).abs() <= duplicateSuppressionWindow;
|
||||
if (_nativeSelectPressed || duplicateCompletedPress) {
|
||||
_suppressedNativeSelectDowns++;
|
||||
final reason = _nativeSelectPressed ? 'native-select-already-down' : 'recent-native-select';
|
||||
_log(
|
||||
'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} '
|
||||
'reason=$reason',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
_nativeSelectPressed = true;
|
||||
_lastAcceptedNativeSelectDownAt = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event is KeyRepeatEvent) {
|
||||
if (_nativeSelectPressed) return false;
|
||||
final lastAcceptedNativeSelectDownAt = _lastAcceptedNativeSelectDownAt;
|
||||
if (lastAcceptedNativeSelectDownAt != null &&
|
||||
now.difference(lastAcceptedNativeSelectDownAt).abs() <= duplicateSuppressionWindow) {
|
||||
_log(
|
||||
'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} '
|
||||
'reason=recent-native-select',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event is KeyUpEvent) {
|
||||
if (_suppressedNativeSelectDowns > 0) {
|
||||
_suppressedNativeSelectDowns--;
|
||||
_log(
|
||||
'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} '
|
||||
'reason=suppressed-native-select-down',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_nativeSelectPressed) {
|
||||
final lastAcceptedNativeSelectUpAt = _lastAcceptedNativeSelectUpAt;
|
||||
if (lastAcceptedNativeSelectUpAt != null &&
|
||||
now.difference(lastAcceptedNativeSelectUpAt).abs() <= duplicateSuppressionWindow) {
|
||||
_log(
|
||||
'consume native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)} '
|
||||
'reason=recent-native-select-up',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_nativeSelectPressed = false;
|
||||
_lastAcceptedNativeSelectUpAt = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void _resetNativeSelectBurstState() {
|
||||
_lastAcceptedNativeSelectDownAt = null;
|
||||
_lastAcceptedNativeSelectUpAt = null;
|
||||
_suppressedNativeSelectDowns = 0;
|
||||
_nativeSelectPressed = false;
|
||||
}
|
||||
|
||||
bool _emitKey(LogicalKeyboardKey logicalKey, {required String source, String? detail}) {
|
||||
if (_duplicateInputGuard.shouldSuppressSyntheticKey(logicalKey)) {
|
||||
_log('suppress key=${_keyName(logicalKey)} source=$source reason=recent-native');
|
||||
@@ -412,9 +232,6 @@ class AppleTvRemoteTouchService {
|
||||
_setTraditionalFocusHighlight();
|
||||
_scheduleFrame();
|
||||
_log('emit key=${_keyName(logicalKey)} source=$source${detail == null ? '' : ' $detail'}');
|
||||
if (_isDirectionalKey(logicalKey)) {
|
||||
_lastDirectionalInputAt = _now();
|
||||
}
|
||||
_simulateKeyPress(logicalKey);
|
||||
return true;
|
||||
}
|
||||
@@ -423,7 +240,6 @@ class AppleTvRemoteTouchService {
|
||||
|
||||
void _resetTouch() {
|
||||
_touchActive = false;
|
||||
_touchActiveNotifier.value = false;
|
||||
_lastSwipeAxis = null;
|
||||
_lastSwipeAt = null;
|
||||
}
|
||||
@@ -480,21 +296,6 @@ class AppleTvRemoteTouchService {
|
||||
return '0x${key.keyId.toRadixString(16)}';
|
||||
}
|
||||
|
||||
bool _isDirectionalKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.arrowUp ||
|
||||
key == LogicalKeyboardKey.arrowDown ||
|
||||
key == LogicalKeyboardKey.arrowLeft ||
|
||||
key == LogicalKeyboardKey.arrowRight;
|
||||
}
|
||||
|
||||
bool _isSelectKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.enter ||
|
||||
key.keyId == 0x0d ||
|
||||
key == LogicalKeyboardKey.numpadEnter ||
|
||||
key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.gameButtonA;
|
||||
}
|
||||
|
||||
bool _isMediaPlaybackKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.mediaPlayPause ||
|
||||
key == LogicalKeyboardKey.mediaPlay ||
|
||||
|
||||
@@ -109,6 +109,20 @@ class GamepadDuplicateInputGuard {
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool isTvosEngineOwnedGamepadButton({required bool isAppleTV, required GamepadButton button}) {
|
||||
if (!isAppleTV) return false;
|
||||
return switch (button) {
|
||||
GamepadButton.dpadUp ||
|
||||
GamepadButton.dpadDown ||
|
||||
GamepadButton.dpadLeft ||
|
||||
GamepadButton.dpadRight ||
|
||||
GamepadButton.a ||
|
||||
GamepadButton.b => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Service that bridges gamepad input to Flutter's focus navigation system.
|
||||
///
|
||||
/// Listens to gamepad events from the `universal_gamepad` package and translates
|
||||
@@ -382,6 +396,10 @@ class GamepadService with WindowListener {
|
||||
_logGamepadDiag('button ignored because window is not focused ${_describeGamepadButton(event)}');
|
||||
return;
|
||||
}
|
||||
if (isTvosEngineOwnedGamepadButton(isAppleTV: PlatformDetector.isAppleTV(), button: event.button)) {
|
||||
_logGamepadDiag('button ignored because tvOS engine owns its key lifecycle ${_describeGamepadButton(event)}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to keyboard mode on any button press
|
||||
if (event.pressed) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import '../media/ids.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -346,13 +345,6 @@ class TvBrowseRail extends StatefulWidget {
|
||||
/// itself, so sidebar flips never rebuild the rail — only the bleed layer.
|
||||
final double? backgroundBleedLeft;
|
||||
|
||||
/// Optional signal that is `true` while an input gesture (e.g. a Siri-remote
|
||||
/// touch) is in progress. When select-suppression is armed during an active
|
||||
/// gesture, it is held until the gesture ends (finger lift) rather than the
|
||||
/// short no-touch timeout — one activation per touch. Generic by design: no
|
||||
/// platform/service coupling here.
|
||||
final ValueListenable<bool>? selectSuppressionGestureSignal;
|
||||
|
||||
const TvBrowseRail({
|
||||
super.key,
|
||||
required this.hubs,
|
||||
@@ -381,7 +373,6 @@ class TvBrowseRail extends StatefulWidget {
|
||||
this.episodePosterModeForHub,
|
||||
this.widePosterScaleForHub,
|
||||
this.backgroundBleedLeft,
|
||||
this.selectSuppressionGestureSignal,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -389,14 +380,6 @@ class TvBrowseRail extends StatefulWidget {
|
||||
}
|
||||
|
||||
class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
// No-touch fallback only: clear suppression even if no select key-up is seen
|
||||
// (e.g. a held-key carry-over on a non-touch remote). Touch-driven clicks use
|
||||
// the gesture path instead, which is bounded by the physical touch.
|
||||
static const _selectSuppressionTimeout = Duration(milliseconds: 220);
|
||||
// Touch path safety net only. The deterministic clear is the touch ending
|
||||
// (finger lift); this guards solely against a dropped touch-end event and is
|
||||
// generous enough never to fire mid-gesture in practice.
|
||||
static const _selectSuppressionGestureBackstop = Duration(seconds: 3);
|
||||
static const _navigationScrollDuration = Duration(milliseconds: 130);
|
||||
static const _repeatNavigationScrollDuration = Duration(milliseconds: 65);
|
||||
static const _scrollCatchUpViewportDistance = 2.5;
|
||||
@@ -426,10 +409,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
List<double> _sectionOffsets = const [];
|
||||
double _sectionMaxScrollExtent = 0;
|
||||
final _selectLongPress = DpadSelectLongPressController();
|
||||
Timer? _selectSuppressionTimer;
|
||||
Timer? _selectSuppressionMaxTimer;
|
||||
VoidCallback? _gestureSignalListener;
|
||||
bool _suppressSelectUntilKeyUp = false;
|
||||
bool _hasUserInteracted = false;
|
||||
bool _hasUserChangedHub = false;
|
||||
int _verticalScrollGeneration = 0;
|
||||
@@ -442,35 +421,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void suppressSelectUntilKeyUp() {
|
||||
_resetLongPressState();
|
||||
_suppressSelectUntilKeyUp = true;
|
||||
_selectSuppressionTimer?.cancel();
|
||||
_selectSuppressionMaxTimer?.cancel();
|
||||
_detachGestureSignalListener();
|
||||
|
||||
final gesture = widget.selectSuppressionGestureSignal;
|
||||
if (gesture != null && gesture.value) {
|
||||
// A Siri-remote touch is in progress. The stray select that would auto-play
|
||||
// a Continue Watching item is delivered within this same uninterrupted
|
||||
// touch: one physical press navigates Home, then bounces a second select
|
||||
// mid-drag (#1281). Hold suppression until the finger lifts — one
|
||||
// activation per touch, no time heuristic. The next observed select key-up
|
||||
// also clears it; the backstop only guards against a dropped touch-end.
|
||||
_gestureSignalListener = () {
|
||||
if (!(widget.selectSuppressionGestureSignal?.value ?? false)) {
|
||||
_clearSelectSuppression();
|
||||
}
|
||||
};
|
||||
gesture.addListener(_gestureSignalListener!);
|
||||
_selectSuppressionMaxTimer = Timer(_selectSuppressionGestureBackstop, _clearSelectSuppression);
|
||||
} else {
|
||||
// No touch in progress (held-key carry-over on a non-touch remote): clear
|
||||
// on the next select key-up, with the short legacy safety timeout.
|
||||
_selectSuppressionTimer = Timer(_selectSuppressionTimeout, _clearSelectSuppression);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -604,9 +554,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
@override
|
||||
void dispose() {
|
||||
_selectLongPress.dispose();
|
||||
_selectSuppressionTimer?.cancel();
|
||||
_selectSuppressionMaxTimer?.cancel();
|
||||
_detachGestureSignalListener();
|
||||
_focusNode.removeListener(_handleFocusChange);
|
||||
_focusNode.dispose();
|
||||
_focusModel.dispose();
|
||||
@@ -632,23 +579,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
_selectLongPress.reset();
|
||||
}
|
||||
|
||||
void _clearSelectSuppression() {
|
||||
_selectSuppressionTimer?.cancel();
|
||||
_selectSuppressionTimer = null;
|
||||
_selectSuppressionMaxTimer?.cancel();
|
||||
_selectSuppressionMaxTimer = null;
|
||||
_suppressSelectUntilKeyUp = false;
|
||||
_detachGestureSignalListener();
|
||||
}
|
||||
|
||||
void _detachGestureSignalListener() {
|
||||
final listener = _gestureSignalListener;
|
||||
if (listener != null) {
|
||||
widget.selectSuppressionGestureSignal?.removeListener(listener);
|
||||
_gestureSignalListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasTrailingFor(MediaHub hub) => _trailingFor(hub) != TvRailTrailing.none;
|
||||
|
||||
int _totalItemCount(MediaHub hub) => hub.items.length + (_hasTrailingFor(hub) ? 1 : 0);
|
||||
@@ -705,10 +635,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key.isSelectKey) {
|
||||
if (_suppressSelectUntilKeyUp) {
|
||||
if (event is KeyUpEvent) _clearSelectSuppression();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
_hasUserInteracted = true;
|
||||
|
||||
return _selectLongPress.handleKeyEvent(
|
||||
|
||||
@@ -71,6 +71,30 @@ void main() {
|
||||
expect(shouldPass(isAppleTV: false), isFalse);
|
||||
});
|
||||
|
||||
test('tvOS Menu policy transaction publishes only the settled navigation state', () {
|
||||
var desired = false;
|
||||
final published = <bool>[];
|
||||
final publisher = TvosMenuPolicyPublisher(() => desired, published.add);
|
||||
|
||||
publisher.run(() {
|
||||
desired = true;
|
||||
publisher.update();
|
||||
desired = false;
|
||||
});
|
||||
|
||||
expect(published, [false]);
|
||||
});
|
||||
|
||||
test('tvOS Menu policy publishes retained sidebar Home state immediately', () {
|
||||
final desired = true;
|
||||
final published = <bool>[];
|
||||
final publisher = TvosMenuPolicyPublisher(() => desired, published.add);
|
||||
|
||||
publisher.update();
|
||||
|
||||
expect(published, [true]);
|
||||
});
|
||||
|
||||
test('macOS physical Escape is reserved for native fullscreen only at root Home', () {
|
||||
bool shouldHandle({
|
||||
bool isMacOS = true,
|
||||
|
||||
@@ -134,119 +134,14 @@ void main() {
|
||||
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||
});
|
||||
|
||||
test('click events emit held select key down and up', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('ended', x: 500, y: 500);
|
||||
await harness.send('click_s');
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
|
||||
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
|
||||
|
||||
harness.advance(const Duration(milliseconds: 121));
|
||||
await harness.send('click_s');
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keyDowns, [LogicalKeyboardKey.enter, LogicalKeyboardKey.enter]);
|
||||
expect(harness.keyUps, [LogicalKeyboardKey.enter, LogicalKeyboardKey.enter]);
|
||||
});
|
||||
|
||||
test('native select suppresses click fallback from physical remote path', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select));
|
||||
await harness.send('click_s');
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keyDowns, isEmpty);
|
||||
expect(harness.keyUps, isEmpty);
|
||||
|
||||
harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select));
|
||||
harness.advance(const Duration(milliseconds: 121));
|
||||
await harness.send('click_s');
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
|
||||
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
|
||||
});
|
||||
|
||||
test('native select during click fallback is consumed and releases synthetic select', () async {
|
||||
test('legacy click messages do not synthesize Select', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('click_s');
|
||||
|
||||
expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
|
||||
expect(harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.enter)), isTrue);
|
||||
expect(harness.keyUps, isEmpty);
|
||||
|
||||
expect(harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.enter)), isTrue);
|
||||
|
||||
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
|
||||
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
|
||||
expect(harness.keys, isEmpty);
|
||||
});
|
||||
|
||||
test('native select burst consumes duplicate native pairs', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
expect(harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)), isFalse);
|
||||
expect(harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)), isFalse);
|
||||
|
||||
expect(harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)), isTrue);
|
||||
expect(harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)), isTrue);
|
||||
|
||||
harness.advance(const Duration(milliseconds: 121));
|
||||
|
||||
expect(harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.select)), isFalse);
|
||||
expect(harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select)), isFalse);
|
||||
});
|
||||
|
||||
test('raw native enter suppresses click fallback from tvOS engine path', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
harness.service.handleNativeKeyEvent(_keyDown(_rawEnterKey));
|
||||
await harness.send('click_s');
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keyDowns, isEmpty);
|
||||
expect(harness.keyUps, isEmpty);
|
||||
});
|
||||
|
||||
test('recent directional input suppresses click fallback', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft));
|
||||
await harness.send('click_s');
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keyDowns, isEmpty);
|
||||
expect(harness.keyUps, isEmpty);
|
||||
|
||||
harness.advance(const Duration(milliseconds: 221));
|
||||
await harness.send('click_s');
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
|
||||
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
|
||||
});
|
||||
|
||||
test('synthetic swipe suppresses click fallback', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('move', x: 380, y: 500);
|
||||
await harness.send('click_s');
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||
expect(harness.keyDowns, isEmpty);
|
||||
expect(harness.keyUps, isEmpty);
|
||||
});
|
||||
|
||||
test('cancelled touch does not emit select on a later ended message', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
@@ -257,45 +152,15 @@ void main() {
|
||||
|
||||
expect(harness.keys, isEmpty);
|
||||
});
|
||||
|
||||
test('isTouchActive and listenable track touch start and end', () async {
|
||||
final harness = _Harness();
|
||||
final seen = <bool>[];
|
||||
harness.service.touchActiveListenable.addListener(() => seen.add(harness.service.isTouchActive));
|
||||
|
||||
expect(harness.service.isTouchActive, isFalse);
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
expect(harness.service.isTouchActive, isTrue);
|
||||
|
||||
await harness.send('ended', x: 500, y: 500);
|
||||
expect(harness.service.isTouchActive, isFalse);
|
||||
|
||||
expect(seen, [true, false]);
|
||||
});
|
||||
|
||||
test('cancelled touch clears touch-active state', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
expect(harness.service.isTouchActive, isTrue);
|
||||
|
||||
await harness.send('cancelled');
|
||||
expect(harness.service.isTouchActive, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _Harness {
|
||||
DateTime now = DateTime(2026, 5, 5, 12);
|
||||
final List<LogicalKeyboardKey> keys = [];
|
||||
final List<LogicalKeyboardKey> keyDowns = [];
|
||||
final List<LogicalKeyboardKey> keyUps = [];
|
||||
|
||||
late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService(
|
||||
simulateKeyPress: keys.add,
|
||||
simulateKeyDown: keyDowns.add,
|
||||
simulateKeyUp: keyUps.add,
|
||||
scheduleFrame: () {},
|
||||
now: () => now,
|
||||
swipeThreshold: 100,
|
||||
@@ -310,12 +175,6 @@ class _Harness {
|
||||
}
|
||||
}
|
||||
|
||||
const _rawEnterKey = LogicalKeyboardKey(0x0d);
|
||||
|
||||
KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) {
|
||||
return KeyDownEvent(physicalKey: PhysicalKeyboardKey.enter, logicalKey: logicalKey, timeStamp: Duration.zero);
|
||||
}
|
||||
|
||||
KeyUpEvent _keyUp(LogicalKeyboardKey logicalKey) {
|
||||
return KeyUpEvent(physicalKey: PhysicalKeyboardKey.enter, logicalKey: logicalKey, timeStamp: Duration.zero);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/gamepad_service.dart';
|
||||
import 'package:universal_gamepad/universal_gamepad.dart';
|
||||
|
||||
void main() {
|
||||
group('GamepadDuplicateInputGuard', () {
|
||||
@@ -60,6 +61,23 @@ void main() {
|
||||
expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.gameButtonB), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test('tvOS engine exclusively owns standard navigation buttons', () {
|
||||
for (final button in [
|
||||
GamepadButton.dpadUp,
|
||||
GamepadButton.dpadDown,
|
||||
GamepadButton.dpadLeft,
|
||||
GamepadButton.dpadRight,
|
||||
GamepadButton.a,
|
||||
GamepadButton.b,
|
||||
]) {
|
||||
expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: button), isTrue);
|
||||
}
|
||||
|
||||
expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: GamepadButton.x), isFalse);
|
||||
expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: GamepadButton.leftShoulder), isFalse);
|
||||
expect(isTvosEngineOwnedGamepadButton(isAppleTV: false, button: GamepadButton.a), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) {
|
||||
|
||||
@@ -2041,212 +2041,6 @@ void main() {
|
||||
expect(activations, 1);
|
||||
});
|
||||
|
||||
testWidgets('suppresses transferred select activation until key up', (tester) async {
|
||||
var activations = 0;
|
||||
final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person');
|
||||
final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1);
|
||||
final serverManager = MultiServerManager();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
focusMemory: focusMemory,
|
||||
hubs: [hub],
|
||||
iconForHub: (_, _) => Icons.person_rounded,
|
||||
onActivateItem: (_, _) {
|
||||
activations++;
|
||||
return Future.value(true);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
|
||||
railState.requestFocus();
|
||||
railState.suppressSelectUntilKeyUp();
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(activations, 0);
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(activations, 1);
|
||||
});
|
||||
|
||||
testWidgets('without a gesture signal, suppression clears on the legacy safety timeout', (tester) async {
|
||||
var activations = 0;
|
||||
final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person');
|
||||
final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1);
|
||||
final serverManager = MultiServerManager();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
focusMemory: focusMemory,
|
||||
hubs: [hub],
|
||||
iconForHub: (_, _) => Icons.person_rounded,
|
||||
onActivateItem: (_, _) {
|
||||
activations++;
|
||||
return Future.value(true);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
|
||||
railState.requestFocus();
|
||||
railState.suppressSelectUntilKeyUp();
|
||||
await tester.pump();
|
||||
|
||||
// With no touch gesture, suppression must not outlive the short safety
|
||||
// timeout — a select after it elapses activates normally.
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(activations, 1);
|
||||
});
|
||||
|
||||
testWidgets('an active touch gesture holds select suppression past the legacy window', (tester) async {
|
||||
var activations = 0;
|
||||
final gesture = ValueNotifier<bool>(true);
|
||||
addTearDown(gesture.dispose);
|
||||
final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person');
|
||||
final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1);
|
||||
final serverManager = MultiServerManager();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
focusMemory: focusMemory,
|
||||
hubs: [hub],
|
||||
iconForHub: (_, _) => Icons.person_rounded,
|
||||
selectSuppressionGestureSignal: gesture,
|
||||
onActivateItem: (_, _) {
|
||||
activations++;
|
||||
return Future.value(true);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
|
||||
railState.requestFocus();
|
||||
railState.suppressSelectUntilKeyUp();
|
||||
await tester.pump();
|
||||
|
||||
// Well past the legacy 220ms window, finger still down (gesture active): the
|
||||
// stray same-gesture select (#1281) is still ignored.
|
||||
await tester.pump(const Duration(milliseconds: 1000));
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(activations, 0);
|
||||
|
||||
// Once cleared, deliberate selects work again.
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(activations, 1);
|
||||
});
|
||||
|
||||
testWidgets('ending the gesture clears select suppression before the backstop', (tester) async {
|
||||
var activations = 0;
|
||||
final gesture = ValueNotifier<bool>(true);
|
||||
addTearDown(gesture.dispose);
|
||||
final person = testMediaItem(id: 'person_1', backend: MediaBackend.plex, kind: MediaKind.unknown, title: 'Person');
|
||||
final hub = MediaHub(id: 'people', title: 'People', type: 'person', items: [person], size: 1);
|
||||
final serverManager = MultiServerManager();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
focusMemory: focusMemory,
|
||||
hubs: [hub],
|
||||
iconForHub: (_, _) => Icons.person_rounded,
|
||||
selectSuppressionGestureSignal: gesture,
|
||||
onActivateItem: (_, _) {
|
||||
activations++;
|
||||
return Future.value(true);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
|
||||
railState.requestFocus();
|
||||
railState.suppressSelectUntilKeyUp();
|
||||
await tester.pump();
|
||||
|
||||
// Suppression holds while the gesture is active, past the legacy window.
|
||||
await tester.pump(const Duration(milliseconds: 1000));
|
||||
// Finger lifts -> gesture ends -> suppression clears immediately, well before
|
||||
// the safety backstop.
|
||||
gesture.value = false;
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(activations, 1);
|
||||
});
|
||||
|
||||
testWidgets('does not autofocus unless requested', (tester) async {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
db7b9740fbc38dd7ecead775f2c04cbfced2cab95717f2ed1d00d1f0f4438a55
|
||||
278584b13cf4def805f9ceaa97ee4e318d5e5f75c9222286295135e5691d23ef
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.44.0+3
|
||||
3.44.0+4
|
||||
|
||||
Reference in New Issue
Block a user