fix(tvos): suppress duplicate select playback
This commit is contained in:
@@ -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 ||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user