fix(tvos): suppress duplicate select playback

This commit is contained in:
edde746
2026-06-03 17:31:15 +02:00
parent 92bfae322e
commit f3e108e867
10 changed files with 525 additions and 59 deletions
+15
View File
@@ -3,6 +3,7 @@ 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';
@@ -337,6 +338,7 @@ 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;
@@ -348,6 +350,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (rail != null) {
_pendingTvBrowseRailFocus = false;
rail.requestFocus();
if (suppressSelectUntilKeyUp) rail.suppressSelectUntilKeyUp();
return;
}
}
@@ -363,9 +366,21 @@ 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();
}
@@ -52,6 +52,10 @@ class AppleTvRemoteTouchService {
DateTime? _lastSwipeAt;
DateTime? _lastDirectionalInputAt;
DateTime? _lastSyntheticSelectAt;
DateTime? _lastAcceptedNativeSelectDownAt;
DateTime? _lastAcceptedNativeSelectUpAt;
int _suppressedNativeSelectDowns = 0;
bool _nativeSelectPressed = false;
bool _selectPressedFromClick = false;
AppleTvRemoteTouchService({
@@ -92,6 +96,7 @@ class AppleTvRemoteTouchService {
_channel.setMessageHandler(null);
_unregisterNativeKeyHandler();
_duplicateInputGuard.clear();
_resetNativeSelectBurstState();
_releaseSelectFromClick(source: 'stop');
_resetTouch();
_listening = false;
@@ -103,6 +108,9 @@ 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();
}
@@ -287,6 +295,103 @@ class AppleTvRemoteTouchService {
_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');
@@ -352,6 +457,8 @@ class AppleTvRemoteTouchService {
if (key == LogicalKeyboardKey.arrowLeft) return 'arrowLeft';
if (key == LogicalKeyboardKey.arrowRight) return 'arrowRight';
if (key == LogicalKeyboardKey.enter) return 'enter';
if (key.keyId == 0x0d) return 'rawEnter';
if (key == LogicalKeyboardKey.numpadEnter) return 'numpadEnter';
if (key == LogicalKeyboardKey.select) return 'select';
if (key == LogicalKeyboardKey.gameButtonA) return 'gameButtonA';
if (key == LogicalKeyboardKey.escape) return 'escape';
@@ -368,6 +475,14 @@ class AppleTvRemoteTouchService {
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 ||
+33 -4
View File
@@ -35,6 +35,7 @@ void _logGamepadDiag(String message) {
/// equivalent native key event, which happens with Steam Input on Windows.
class GamepadDuplicateInputGuard {
static const defaultSuppressionWindow = Duration(milliseconds: 120);
static const LogicalKeyboardKey _rawEnterKey = LogicalKeyboardKey(0x0d);
static final Map<LogicalKeyboardKey, Set<LogicalKeyboardKey>> _nativeAliasesBySyntheticKey = {
LogicalKeyboardKey.arrowUp: {LogicalKeyboardKey.arrowUp},
@@ -43,6 +44,7 @@ class GamepadDuplicateInputGuard {
LogicalKeyboardKey.arrowRight: {LogicalKeyboardKey.arrowRight},
LogicalKeyboardKey.enter: {
LogicalKeyboardKey.enter,
_rawEnterKey,
LogicalKeyboardKey.numpadEnter,
LogicalKeyboardKey.select,
LogicalKeyboardKey.gameButtonA,
@@ -68,9 +70,10 @@ class GamepadDuplicateInputGuard {
GamepadDuplicateInputGuard({
DateTime Function()? now,
this._enabled,
bool Function()? enabled,
this.suppressionWindow = defaultSuppressionWindow,
}) : _now = now ?? DateTime.now;
}) : _now = now ?? DateTime.now,
_enabled = enabled;
bool get _isEnabled => _enabled?.call() ?? true;
@@ -164,6 +167,7 @@ class GamepadService with WindowListener {
// Track button states to prevent repeated events from button holds
final Set<GamepadButton> _pressedButtons = {};
final Set<GamepadButton> _suppressedButtons = {};
final Map<LogicalKeyboardKey, FocusNode> _heldFocusNodes = {};
// Whether the app window is currently focused — ignore gamepad input when false
bool _windowFocused = true;
@@ -225,6 +229,7 @@ class GamepadService with WindowListener {
_subscription = null;
_duplicateInputGuard.clear();
_suppressedButtons.clear();
_heldFocusNodes.clear();
if (_isDesktop) {
windowManager.removeListener(this);
}
@@ -253,6 +258,7 @@ class GamepadService with WindowListener {
}
_pressedButtons.clear();
_suppressedButtons.clear();
_heldFocusNodes.clear();
_duplicateInputGuard.clear();
// Reset analog stick state so re-focus doesn't inherit stale direction
@@ -294,6 +300,7 @@ class GamepadService with WindowListener {
_stopDirectionRepeat();
_pressedButtons.clear();
_suppressedButtons.clear();
_heldFocusNodes.clear();
_duplicateInputGuard.clear();
}
@@ -527,6 +534,13 @@ class GamepadService with WindowListener {
}
void _dispatchKeyDown(LogicalKeyboardKey logicalKey) {
final focusNode = FocusManager.instance.primaryFocus;
if (focusNode == null) {
_logGamepadDiag('dispatchKeyDown dropped reason=no-focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
return;
}
_heldFocusNodes[logicalKey] = focusNode;
_logGamepadDiag('dispatchKeyDown logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
_dispatchKeyEvent(
KeyDownEvent(
@@ -535,10 +549,24 @@ class GamepadService with WindowListener {
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
deviceType: ui.KeyEventDeviceType.gamepad,
),
startNode: focusNode,
);
}
void _dispatchKeyUp(LogicalKeyboardKey logicalKey) {
final heldFocusNode = _heldFocusNodes.remove(logicalKey);
final focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus;
if (focusNode == null) {
_logGamepadDiag('dispatchKeyUp dropped reason=no-focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
return;
}
if (heldFocusNode != null && heldFocusNode.context == null) {
_logGamepadDiag(
'dispatchKeyUp dropped reason=held-focus-detached logical=${logicalKey.keyLabel}/${logicalKey.keyId}',
);
return;
}
_logGamepadDiag('dispatchKeyUp logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
_dispatchKeyEvent(
KeyUpEvent(
@@ -547,11 +575,12 @@ class GamepadService with WindowListener {
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
deviceType: ui.KeyEventDeviceType.gamepad,
),
startNode: focusNode,
);
}
void _dispatchKeyEvent(KeyEvent event) {
FocusNode? node = FocusManager.instance.primaryFocus;
void _dispatchKeyEvent(KeyEvent event, {FocusNode? startNode}) {
FocusNode? node = startNode ?? FocusManager.instance.primaryFocus;
_logGamepadDiag('dispatch start focus=${node?.debugLabel} key=(${_describeGamepadKeyEvent(event)})');
while (node != null) {
if (node.onKeyEvent != null) {
+9 -1
View File
@@ -15,6 +15,8 @@ void _logKeySimulator(String message) {
TextInputDiagnostics.log('KeySimulator', message);
}
final Map<LogicalKeyboardKey, FocusNode> _heldFocusNodes = {};
/// Shared utility for simulating key press events through the focus tree.
///
/// Used by companion remotes, Apple TV touch input, and gamepad services to
@@ -63,6 +65,7 @@ void simulateKeyDown(LogicalKeyboardKey logicalKey) {
final focusNode = FocusManager.instance.primaryFocus;
if (focusNode == null) return;
_heldFocusNodes[logicalKey] = focusNode;
_dispatchKeyEvent(
focusNode,
KeyDownEvent(
@@ -80,8 +83,13 @@ void simulateKeyUp(LogicalKeyboardKey logicalKey) {
_logKeySimulator('simulateKeyUp scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
scheduleFrameIfIdle();
SchedulerBinding.instance.addPostFrameCallback((_) {
final focusNode = FocusManager.instance.primaryFocus;
final heldFocusNode = _heldFocusNodes.remove(logicalKey);
final focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus;
if (focusNode == null) return;
if (heldFocusNode != null && heldFocusNode.context == null) {
_logKeySimulator('simulateKeyUp dropped detached held focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
return;
}
_dispatchKeyEvent(
focusNode,
+142 -54
View File
@@ -18,6 +18,64 @@ import 'app_logger.dart';
const String kVideoPlayerRouteName = '/video_player';
class VideoPlayerNavigationInFlightGuard {
final Set<String> _keys = <String>{};
bool tryStart(
MediaItem metadata, {
required int mediaIndex,
required String? selectedMediaSourceId,
required TranscodeQualityPreset? selectedQualityPreset,
required bool isOffline,
}) {
return _keys.add(
_keyFor(
metadata,
mediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
),
);
}
void finish(
MediaItem metadata, {
required int mediaIndex,
required String? selectedMediaSourceId,
required TranscodeQualityPreset? selectedQualityPreset,
required bool isOffline,
}) {
_keys.remove(
_keyFor(
metadata,
mediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
),
);
}
String _keyFor(
MediaItem metadata, {
required int mediaIndex,
required String? selectedMediaSourceId,
required TranscodeQualityPreset? selectedQualityPreset,
required bool isOffline,
}) {
return [
metadata.globalKey,
mediaIndex,
selectedMediaSourceId ?? '',
selectedQualityPreset?.name ?? 'auto',
isOffline,
].join('|');
}
}
final _videoPlayerNavigationInFlightGuard = VideoPlayerNavigationInFlightGuard();
class WatchTogetherPlaybackNavigationException implements Exception {
final String message;
@@ -82,24 +140,53 @@ Future<bool?> navigateToVideoPlayer(
} catch (_) {}
}
// Check if external player is enabled
try {
final settingsService = await SettingsService.getInstance();
if (settingsService.read(SettingsService.useExternalPlayer)) {
bool launched = false;
var markedInFlight = false;
if (!usePushReplacement) {
markedInFlight = _videoPlayerNavigationInFlightGuard.tryStart(
metadata,
mediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
);
if (!markedInFlight) {
appLogger.d(
'Video player navigation already in flight for ${metadata.id} (mediaIndex=$mediaIndex), '
'skipping duplicate navigation',
);
return null;
}
}
if (isOffline) {
final globalKey = metadata.globalKey;
final videoPath = await downloadProvider.getVideoFilePath(
globalKey,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
if (videoPath != null && context.mounted) {
final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath';
try {
// Check if external player is enabled
try {
final settingsService = await SettingsService.getInstance();
if (settingsService.read(SettingsService.useExternalPlayer)) {
bool launched = false;
if (isOffline) {
final globalKey = metadata.globalKey;
final videoPath = await downloadProvider.getVideoFilePath(
globalKey,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
if (videoPath != null && context.mounted) {
final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath';
launched = await ExternalPlayerService.launch(
context: context,
videoUrl: videoUrl,
metadata: metadata,
client: mediaClient,
offlineWatchService: offlineWatchService,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
}
} else if (context.mounted) {
launched = await ExternalPlayerService.launch(
context: context,
videoUrl: videoUrl,
metadata: metadata,
client: mediaClient,
offlineWatchService: offlineWatchService,
@@ -107,50 +194,51 @@ Future<bool?> navigateToVideoPlayer(
mediaSourceId: selectedMediaSourceId,
);
}
} else if (context.mounted) {
launched = await ExternalPlayerService.launch(
context: context,
metadata: metadata,
client: mediaClient,
offlineWatchService: offlineWatchService,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
if (launched) return null;
}
if (launched) return null;
} catch (e) {
appLogger.w('External player launch failed, falling back to built-in player', error: e);
}
} catch (e) {
appLogger.w('External player launch failed, falling back to built-in player', error: e);
}
// Prevent stacking an identical video player when already active
if (!usePushReplacement &&
VideoPlayerScreenState.activeId == metadata.id &&
VideoPlayerScreenState.activeMediaIndex == mediaIndex) {
appLogger.d(
'Video player already active for ${metadata.id} (mediaIndex=$mediaIndex), skipping duplicate navigation',
// Prevent stacking an identical video player when already active
if (!usePushReplacement &&
VideoPlayerScreenState.activeId == metadata.id &&
VideoPlayerScreenState.activeMediaIndex == mediaIndex) {
appLogger.d(
'Video player already active for ${metadata.id} (mediaIndex=$mediaIndex), skipping duplicate navigation',
);
return null;
}
final route = PageRouteBuilder<bool>(
settings: const RouteSettings(name: kVideoPlayerRouteName),
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: metadata,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
selectedMediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
);
return null;
return usePushReplacement ? navigator.pushReplacement<bool, bool>(route) : navigator.push<bool>(route);
} finally {
if (markedInFlight) {
_videoPlayerNavigationInFlightGuard.finish(
metadata,
mediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
);
}
}
final route = PageRouteBuilder<bool>(
settings: const RouteSettings(name: kVideoPlayerRouteName),
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
metadata: metadata,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
selectedMediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
selectedQualityPreset: selectedQualityPreset,
isOffline: isOffline,
),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
);
return usePushReplacement ? navigator.pushReplacement<bool, bool>(route) : navigator.push<bool>(route);
}
/// Navigates to the video player and optionally refreshes content when returning.
+24
View File
@@ -334,6 +334,7 @@ class TvBrowseRail extends StatefulWidget {
class TvBrowseRailState extends State<TvBrowseRail> {
static const _longPressDuration = Duration(milliseconds: 500);
static const _selectSuppressionTimeout = Duration(milliseconds: 220);
static const _navigationScrollDuration = Duration(milliseconds: 130);
static const _repeatNavigationScrollDuration = Duration(milliseconds: 65);
static const _scrollCatchUpViewportDistance = 2.5;
@@ -352,8 +353,10 @@ class TvBrowseRailState extends State<TvBrowseRail> {
List<double> _sectionOffsets = const [];
double _sectionMaxScrollExtent = 0;
Timer? _longPressTimer;
Timer? _selectSuppressionTimer;
bool _isSelectKeyDown = false;
bool _longPressTriggered = false;
bool _suppressSelectUntilKeyUp = false;
bool _hasUserChangedHub = false;
bool _hasUserChangedItem = false;
@@ -364,6 +367,15 @@ class TvBrowseRailState extends State<TvBrowseRail> {
_focusNode.requestFocus();
}
void suppressSelectUntilKeyUp() {
_resetLongPressState();
_suppressSelectUntilKeyUp = true;
_selectSuppressionTimer?.cancel();
_selectSuppressionTimer = Timer(_selectSuppressionTimeout, () {
_suppressSelectUntilKeyUp = false;
});
}
@override
void initState() {
super.initState();
@@ -452,6 +464,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
@override
void dispose() {
_longPressTimer?.cancel();
_selectSuppressionTimer?.cancel();
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
for (final controller in _scrollControllers.values) {
@@ -473,6 +486,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
_longPressTriggered = false;
}
void _clearSelectSuppression() {
_selectSuppressionTimer?.cancel();
_selectSuppressionTimer = null;
_suppressSelectUntilKeyUp = false;
}
int _totalItemCount(MediaHub hub) => hub.items.length + (hub.more ? 1 : 0);
bool _isPersonHub(MediaHub hub) => TvBrowseRailLayout.isPersonHub(hub);
@@ -517,6 +536,11 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final key = event.logicalKey;
if (key.isSelectKey) {
if (_suppressSelectUntilKeyUp) {
if (event is KeyUpEvent) _clearSelectSuppression();
return KeyEventResult.handled;
}
if (event is KeyDownEvent) {
if (!_isSelectKeyDown) {
_isSelectKeyDown = true;
@@ -172,6 +172,50 @@ void main() {
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
});
test('native select during click fallback is consumed and releases synthetic 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]);
});
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();
@@ -240,6 +284,8 @@ class _Harness {
}
}
const _rawEnterKey = LogicalKeyboardKey(0x0d);
KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) {
return KeyDownEvent(physicalKey: PhysicalKeyboardKey.enter, logicalKey: logicalKey, timeStamp: Duration.zero);
}
+53
View File
@@ -32,6 +32,59 @@ void main() {
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
});
testWidgets('simulateKeyUp returns to the key-down focus when focus changes', (tester) async {
final firstNode = FocusNode(debugLabel: 'first');
final secondNode = FocusNode(debugLabel: 'second');
addTearDown(firstNode.dispose);
addTearDown(secondNode.dispose);
final firstEvents = <KeyEvent>[];
final secondEvents = <KeyEvent>[];
await tester.pumpWidget(
MaterialApp(
home: Column(
children: [
Focus(
focusNode: firstNode,
onKeyEvent: (_, event) {
firstEvents.add(event);
return KeyEventResult.handled;
},
child: const SizedBox(width: 10, height: 10),
),
Focus(
focusNode: secondNode,
onKeyEvent: (_, event) {
secondEvents.add(event);
return KeyEventResult.handled;
},
child: const SizedBox(width: 10, height: 10),
),
],
),
),
);
firstNode.requestFocus();
await tester.pump();
simulateKeyDown(LogicalKeyboardKey.enter);
await tester.pump();
expect(firstEvents, hasLength(1));
expect(firstEvents.single, isA<KeyDownEvent>());
secondNode.requestFocus();
await tester.pump();
expect(secondNode.hasPrimaryFocus, isTrue);
simulateKeyUp(LogicalKeyboardKey.enter);
await tester.pump();
expect(firstEvents, hasLength(2));
expect(firstEvents.last, isA<KeyUpEvent>());
expect(secondEvents, isEmpty);
});
testWidgets('simulateKeyPress stops at skipRemainingHandlers', (tester) async {
final childEvents = <KeyEvent>[];
final parentEvents = <KeyEvent>[];
@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/utils/video_player_navigation.dart';
void main() {
test('in-flight video player navigation rejects duplicate requests', () {
final guard = VideoPlayerNavigationInFlightGuard();
final item = MediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
serverId: 'server_1',
);
expect(
guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false),
isTrue,
);
expect(
guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false),
isFalse,
);
expect(
guard.tryStart(item, mediaIndex: 1, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false),
isTrue,
);
guard.finish(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false);
expect(
guard.tryStart(item, mediaIndex: 0, selectedMediaSourceId: null, selectedQualityPreset: null, isOffline: false),
isTrue,
);
});
}
+50
View File
@@ -1228,6 +1228,56 @@ void main() {
expect(activations, 1);
});
testWidgets('suppresses transferred select activation until key up', (tester) async {
var activations = 0;
final person = MediaItem(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(
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('does not autofocus unless requested', (tester) async {
FocusManager.instance.primaryFocus?.unfocus();