fix(tvos): make remote input lifecycle engine-owned

This commit is contained in:
edde746
2026-07-26 03:41:19 +02:00
parent c9543d4af0
commit 1b3c74550f
11 changed files with 111 additions and 651 deletions
-19
View File
@@ -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,
);
}
+47 -8
View File
@@ -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 ||
+18
View File
@@ -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) {
-74
View File
@@ -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(