fix(tvos): stabilize Siri Remote input
This commit is contained in:
@@ -31,6 +31,7 @@ import 'services/native_window_service.dart';
|
||||
import 'services/fullscreen_state_manager.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'utils/platform_detector.dart';
|
||||
import 'services/apple_tv_remote_touch_service.dart';
|
||||
import 'services/discord_rpc_service.dart';
|
||||
import 'services/gamepad_service.dart';
|
||||
import 'services/trakt/trakt_scrobble_service.dart';
|
||||
@@ -214,6 +215,9 @@ Future<void> _bootstrapApp() async {
|
||||
// Initialize gamepad service (all platforms — universal_gamepad auto-registers
|
||||
// and intercepts input events, so we must listen to re-dispatch them)
|
||||
GamepadService.instance.start();
|
||||
if (PlatformDetector.isAppleTV()) {
|
||||
AppleTvRemoteTouchService.instance.start();
|
||||
}
|
||||
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
unawaited(DiscordRPCService.instance.initialize());
|
||||
|
||||
@@ -6,7 +6,6 @@ import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/focus_theme.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../focus/key_repeat_helper.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
@@ -28,6 +27,8 @@ class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProvid
|
||||
late AnimationController _shakeController;
|
||||
late Animation<double> _shakeAnimation;
|
||||
final _pinInputKey = GlobalKey<_TvPinInputState>();
|
||||
final _cancelFocusNode = FocusNode(debugLabel: 'PinCancelButton');
|
||||
final _submitFocusNode = FocusNode(debugLabel: 'PinSubmitButton');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -52,6 +53,8 @@ class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProvid
|
||||
@override
|
||||
void dispose() {
|
||||
_shakeController.dispose();
|
||||
_cancelFocusNode.dispose();
|
||||
_submitFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -63,6 +66,18 @@ class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProvid
|
||||
Navigator.of(context).pop(null);
|
||||
}
|
||||
|
||||
void _focusPinDigit(int index) {
|
||||
_pinInputKey.currentState?._requestDigitFocus(index);
|
||||
}
|
||||
|
||||
void _focusSubmit() {
|
||||
_submitFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _focusCancel() {
|
||||
_cancelFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -93,6 +108,7 @@ class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProvid
|
||||
hasError: widget.errorMessage != null,
|
||||
isMobile: isMobile,
|
||||
isTV: isTV,
|
||||
onMoveToSubmit: isMobile ? null : _focusSubmit,
|
||||
),
|
||||
if (widget.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
@@ -102,12 +118,20 @@ class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProvid
|
||||
),
|
||||
actions: [
|
||||
FocusableButton(
|
||||
focusNode: _cancelFocusNode,
|
||||
onPressed: _cancel,
|
||||
onNavigateRight: isMobile ? null : _focusSubmit,
|
||||
onNavigateUp: () => _focusPinDigit(0),
|
||||
onBack: _cancel,
|
||||
child: TextButton(onPressed: _cancel, child: Text(t.common.cancel)),
|
||||
),
|
||||
if (!isMobile)
|
||||
FocusableButton(
|
||||
focusNode: _submitFocusNode,
|
||||
onPressed: () => _pinInputKey.currentState?._trySubmit(),
|
||||
onNavigateLeft: _focusCancel,
|
||||
onNavigateUp: () => _focusPinDigit(3),
|
||||
onBack: _cancel,
|
||||
child: FilledButton(
|
||||
onPressed: () => _pinInputKey.currentState?._trySubmit(),
|
||||
child: Text(t.common.submit),
|
||||
@@ -125,6 +149,7 @@ class _TvPinInput extends StatefulWidget {
|
||||
final bool hasError;
|
||||
final bool isMobile;
|
||||
final bool isTV;
|
||||
final VoidCallback? onMoveToSubmit;
|
||||
|
||||
const _TvPinInput({
|
||||
super.key,
|
||||
@@ -133,13 +158,14 @@ class _TvPinInput extends StatefulWidget {
|
||||
required this.hasError,
|
||||
required this.isMobile,
|
||||
required this.isTV,
|
||||
this.onMoveToSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TvPinInput> createState() => _TvPinInputState();
|
||||
}
|
||||
|
||||
class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInput>, ControllerDisposerMixin {
|
||||
class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin {
|
||||
final List<int?> _digits = [null, null, null, null];
|
||||
int _activeIndex = 0;
|
||||
bool _isFocused = false;
|
||||
@@ -168,7 +194,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
stopRepeat();
|
||||
_focusNode.dispose();
|
||||
for (final node in _mobileFocusNodes) {
|
||||
node.dispose();
|
||||
@@ -199,6 +224,12 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
if (pin != null) widget.onSubmit(pin);
|
||||
}
|
||||
|
||||
void _requestDigitFocus(int index) {
|
||||
final nextIndex = index < 0 ? 0 : (index > 3 ? 3 : index);
|
||||
setState(() => _activeIndex = nextIndex);
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _incrementDigit() {
|
||||
setState(() {
|
||||
_digits[_activeIndex] = ((_digits[_activeIndex] ?? -1) + 1) % 10;
|
||||
@@ -243,6 +274,9 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
final backResult = handleBackKeyAction(event, widget.onCancel);
|
||||
if (backResult != KeyEventResult.ignored) return backResult;
|
||||
|
||||
final selectResult = handleOneShotSelect(event, _trySubmit);
|
||||
if (selectResult != KeyEventResult.ignored) return selectResult;
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
// Number key input (desktop only, TV uses d-pad)
|
||||
if (!widget.isTV) {
|
||||
@@ -268,16 +302,18 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.isActionable) {
|
||||
// Up arrow → increment digit
|
||||
if (key.isUpKey) {
|
||||
startRepeat(_incrementDigit);
|
||||
_incrementDigit();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Down arrow → decrement digit
|
||||
if (key.isDownKey) {
|
||||
startRepeat(_decrementDigit);
|
||||
_decrementDigit();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -295,20 +331,7 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
setState(() => _activeIndex++);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// At rightmost digit, let focus move to submit button
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// Select / Enter → submit
|
||||
if (key.isSelectKey) {
|
||||
_trySubmit();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
if (event is KeyUpEvent) {
|
||||
if (key.isUpKey || key.isDownKey) {
|
||||
stopRepeat();
|
||||
widget.onMoveToSubmit?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
@@ -367,7 +390,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
||||
autofocus: true,
|
||||
onFocusChange: (hasFocus) {
|
||||
setState(() => _isFocused = hasFocus);
|
||||
if (!hasFocus) stopRepeat();
|
||||
},
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildDigitRow(context, showArrows: showArrows),
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/key_event_simulator.dart' as key_sim;
|
||||
import 'gamepad_service.dart';
|
||||
|
||||
/// Bridges tvOS touch-surface events from Apple's iOS Remote app into the
|
||||
/// focus-tree key events Plezy already handles for D-pad navigation.
|
||||
class AppleTvRemoteTouchService {
|
||||
static const String _channelName = 'flutter/gamepadtouchevent';
|
||||
static const double defaultSwipeThreshold = 180;
|
||||
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 VoidCallback _scheduleFrame;
|
||||
final DateTime Function() _now;
|
||||
final GamepadDuplicateInputGuard _duplicateInputGuard;
|
||||
final double swipeThreshold;
|
||||
final Duration swipeRepeatInterval;
|
||||
final Duration clickAfterDirectionSuppression;
|
||||
|
||||
bool _listening = false;
|
||||
bool _nativeKeyHandlerRegistered = false;
|
||||
bool _touchActive = false;
|
||||
double _anchorX = 0;
|
||||
double _anchorY = 0;
|
||||
DateTime? _lastSwipeAt;
|
||||
DateTime? _lastDirectionalInputAt;
|
||||
DateTime? _lastSyntheticSelectAt;
|
||||
|
||||
AppleTvRemoteTouchService({
|
||||
BasicMessageChannel<dynamic>? channel,
|
||||
void Function(LogicalKeyboardKey logicalKey)? simulateKeyPress,
|
||||
VoidCallback? scheduleFrame,
|
||||
DateTime Function()? now,
|
||||
GamepadDuplicateInputGuard? duplicateInputGuard,
|
||||
Duration duplicateSuppressionWindow = GamepadDuplicateInputGuard.defaultSuppressionWindow,
|
||||
this.swipeThreshold = defaultSwipeThreshold,
|
||||
this.swipeRepeatInterval = defaultSwipeRepeatInterval,
|
||||
this.clickAfterDirectionSuppression = defaultClickAfterDirectionSuppression,
|
||||
}) : _channel = channel ?? const BasicMessageChannel<dynamic>(_channelName, JSONMessageCodec()),
|
||||
_simulateKeyPress = simulateKeyPress ?? key_sim.simulateKeyPress,
|
||||
_scheduleFrame = scheduleFrame ?? key_sim.scheduleFrameIfIdle,
|
||||
_now = now ?? DateTime.now,
|
||||
_duplicateInputGuard =
|
||||
duplicateInputGuard ?? GamepadDuplicateInputGuard(now: now, suppressionWindow: duplicateSuppressionWindow);
|
||||
|
||||
void start() {
|
||||
if (_listening) return;
|
||||
_channel.setMessageHandler(handleMessage);
|
||||
_registerNativeKeyHandler();
|
||||
_listening = true;
|
||||
appLogger.i('AppleTvRemoteTouchService: Listening for tvOS touch remote events');
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (!_listening) return;
|
||||
_channel.setMessageHandler(null);
|
||||
_unregisterNativeKeyHandler();
|
||||
_duplicateInputGuard.clear();
|
||||
_resetTouch();
|
||||
_listening = false;
|
||||
}
|
||||
|
||||
bool handleNativeKeyEvent(KeyEvent event) {
|
||||
_log('native ${_eventTypeName(event)} logical=${_keyName(event.logicalKey)}');
|
||||
if (event is KeyDownEvent && _isDirectionalKey(event.logicalKey)) {
|
||||
_lastDirectionalInputAt = _now();
|
||||
}
|
||||
return _duplicateInputGuard.handleNativeKeyEvent(event);
|
||||
}
|
||||
|
||||
Future<void> handleMessage(dynamic arguments) async {
|
||||
if (arguments is! Map) {
|
||||
_log('ignore message reason=not-map valueType=${arguments.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
final type = arguments['type'];
|
||||
if (type is! String) {
|
||||
_log('ignore message reason=missing-type args=$arguments');
|
||||
return;
|
||||
}
|
||||
|
||||
_logTouch(type, arguments);
|
||||
|
||||
switch (type) {
|
||||
case 'started':
|
||||
final position = _positionFrom(arguments);
|
||||
if (position == null) return;
|
||||
_startTouch(position.$1, position.$2);
|
||||
case 'move':
|
||||
final position = _positionFrom(arguments);
|
||||
if (position == null) return;
|
||||
_moveTouch(position.$1, position.$2);
|
||||
case 'ended':
|
||||
final position = _positionFrom(arguments);
|
||||
if (position == null) {
|
||||
_resetTouch();
|
||||
return;
|
||||
}
|
||||
_moveTouch(position.$1, position.$2);
|
||||
_resetTouch();
|
||||
case 'cancelled':
|
||||
_resetTouch();
|
||||
case 'click_e':
|
||||
_emitSelect();
|
||||
case 'click_s':
|
||||
case 'loc':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(double, double)? _positionFrom(Map<dynamic, dynamic> arguments) {
|
||||
final x = _toDouble(arguments['x']);
|
||||
final y = _toDouble(arguments['y']);
|
||||
if (x == null || y == null) return null;
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
double? _toDouble(Object? value) {
|
||||
if (value is num) return value.toDouble();
|
||||
return null;
|
||||
}
|
||||
|
||||
void _startTouch(double x, double y) {
|
||||
_touchActive = true;
|
||||
_anchorX = x;
|
||||
_anchorY = y;
|
||||
_lastSwipeAt = null;
|
||||
}
|
||||
|
||||
void _moveTouch(double x, double y) {
|
||||
if (!_touchActive) {
|
||||
_log('ignore touch-move reason=no-active-touch x=${_formatDouble(x)} y=${_formatDouble(y)}');
|
||||
return;
|
||||
}
|
||||
|
||||
final deltaX = _anchorX - x;
|
||||
final deltaY = _anchorY - y;
|
||||
if (deltaX.abs() < swipeThreshold && deltaY.abs() < swipeThreshold) return;
|
||||
|
||||
final now = _now();
|
||||
final lastSwipeAt = _lastSwipeAt;
|
||||
if (lastSwipeAt != null && now.difference(lastSwipeAt) < swipeRepeatInterval) {
|
||||
final age = now.difference(lastSwipeAt).inMilliseconds;
|
||||
_log(
|
||||
'suppress swipe reason=repeat-cooldown age=${age}ms dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final logicalKey = deltaX.abs() >= deltaY.abs()
|
||||
? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight)
|
||||
: (deltaY >= 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown);
|
||||
|
||||
_emitKey(logicalKey, source: 'swipe', detail: 'dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}');
|
||||
_anchorX = x;
|
||||
_anchorY = y;
|
||||
_lastSwipeAt = now;
|
||||
}
|
||||
|
||||
void _emitSelect() {
|
||||
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_e 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_e reason=recent-synthetic-select age=${age}ms',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_emitKey(LogicalKeyboardKey.enter, source: 'click_e')) {
|
||||
_lastSyntheticSelectAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
bool _emitKey(LogicalKeyboardKey logicalKey, {required String source, String? detail}) {
|
||||
if (_duplicateInputGuard.shouldSuppressSyntheticKey(logicalKey)) {
|
||||
_log('suppress key=${_keyName(logicalKey)} source=$source reason=recent-native');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setTraditionalFocusHighlight();
|
||||
_scheduleFrame();
|
||||
_log('emit key=${_keyName(logicalKey)} source=$source${detail == null ? '' : ' $detail'}');
|
||||
if (_isDirectionalKey(logicalKey)) {
|
||||
_lastDirectionalInputAt = _now();
|
||||
}
|
||||
_simulateKeyPress(logicalKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
Duration get duplicateSuppressionWindow => _duplicateInputGuard.suppressionWindow;
|
||||
|
||||
void _resetTouch() {
|
||||
_touchActive = false;
|
||||
_lastSwipeAt = null;
|
||||
}
|
||||
|
||||
void _registerNativeKeyHandler() {
|
||||
if (_nativeKeyHandlerRegistered) return;
|
||||
HardwareKeyboard.instance.addHandler(handleNativeKeyEvent);
|
||||
_nativeKeyHandlerRegistered = true;
|
||||
}
|
||||
|
||||
void _unregisterNativeKeyHandler() {
|
||||
if (!_nativeKeyHandlerRegistered) return;
|
||||
HardwareKeyboard.instance.removeHandler(handleNativeKeyEvent);
|
||||
_nativeKeyHandlerRegistered = false;
|
||||
}
|
||||
|
||||
void _setTraditionalFocusHighlight() {
|
||||
if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) {
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
|
||||
}
|
||||
}
|
||||
|
||||
void _logTouch(String type, Map<dynamic, dynamic> arguments) {
|
||||
final x = _toDouble(arguments['x']);
|
||||
final y = _toDouble(arguments['y']);
|
||||
_log('touch type=$type x=${_formatDouble(x)} y=${_formatDouble(y)} active=$_touchActive');
|
||||
}
|
||||
|
||||
void _log(String message) {
|
||||
appLogger.d('AppleTvRemoteTouchService: $message');
|
||||
}
|
||||
|
||||
String _eventTypeName(KeyEvent event) {
|
||||
if (event is KeyDownEvent) return 'keydown';
|
||||
if (event is KeyRepeatEvent) return 'keyrepeat';
|
||||
if (event is KeyUpEvent) return 'keyup';
|
||||
return event.runtimeType.toString();
|
||||
}
|
||||
|
||||
String _keyName(LogicalKeyboardKey key) {
|
||||
if (key == LogicalKeyboardKey.arrowUp) return 'arrowUp';
|
||||
if (key == LogicalKeyboardKey.arrowDown) return 'arrowDown';
|
||||
if (key == LogicalKeyboardKey.arrowLeft) return 'arrowLeft';
|
||||
if (key == LogicalKeyboardKey.arrowRight) return 'arrowRight';
|
||||
if (key == LogicalKeyboardKey.enter) return 'enter';
|
||||
if (key == LogicalKeyboardKey.select) return 'select';
|
||||
if (key == LogicalKeyboardKey.gameButtonA) return 'gameButtonA';
|
||||
if (key == LogicalKeyboardKey.escape) return 'escape';
|
||||
return '0x${key.keyId.toRadixString(16)}';
|
||||
}
|
||||
|
||||
bool _isDirectionalKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.arrowUp ||
|
||||
key == LogicalKeyboardKey.arrowDown ||
|
||||
key == LogicalKeyboardKey.arrowLeft ||
|
||||
key == LogicalKeyboardKey.arrowRight;
|
||||
}
|
||||
|
||||
String _formatDouble(double? value) {
|
||||
if (value == null) return 'n/a';
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Shared utility for simulating key press events through the focus tree.
|
||||
///
|
||||
/// Used by both [CompanionRemoteReceiver] and [GamepadService] to translate
|
||||
/// external input (remote commands, gamepad buttons) into focus-tree key events.
|
||||
/// Used by companion remotes, Apple TV touch input, and gamepad services to
|
||||
/// translate external input into focus-tree key events.
|
||||
void simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
final focusNode = FocusManager.instance.primaryFocus;
|
||||
@@ -60,6 +60,7 @@ PhysicalKeyboardKey _getPhysicalKey(LogicalKeyboardKey logicalKey) {
|
||||
if (logicalKey == LogicalKeyboardKey.arrowLeft) return PhysicalKeyboardKey.arrowLeft;
|
||||
if (logicalKey == LogicalKeyboardKey.arrowRight) return PhysicalKeyboardKey.arrowRight;
|
||||
if (logicalKey == LogicalKeyboardKey.enter) return PhysicalKeyboardKey.enter;
|
||||
if (logicalKey == LogicalKeyboardKey.select) return PhysicalKeyboardKey.select;
|
||||
if (logicalKey == LogicalKeyboardKey.escape) return PhysicalKeyboardKey.escape;
|
||||
if (logicalKey == LogicalKeyboardKey.space) return PhysicalKeyboardKey.space;
|
||||
if (logicalKey == LogicalKeyboardKey.contextMenu) return PhysicalKeyboardKey.contextMenu;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: plezy
|
||||
description: "A beautiful Plex client for Flutter"
|
||||
publish_to: "none"
|
||||
version: 2.0.0+80
|
||||
version: 2.0.0+82
|
||||
|
||||
environment:
|
||||
sdk: ">=3.10.7 <4.0.0"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/apple_tv_remote_touch_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AppleTvRemoteTouchService', () {
|
||||
test('emits repeated horizontal swipes only after the repeat interval', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('move', x: 380, y: 490);
|
||||
await harness.send('move', x: 260, y: 490);
|
||||
|
||||
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||
|
||||
harness.advance(const Duration(milliseconds: 141));
|
||||
await harness.send('move', x: 260, y: 490);
|
||||
|
||||
expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]);
|
||||
});
|
||||
|
||||
test('uses the dominant vertical axis for swipes', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('move', x: 540, y: 380);
|
||||
|
||||
expect(harness.keys, [LogicalKeyboardKey.arrowUp]);
|
||||
});
|
||||
|
||||
test('short touch without a click event does not emit select', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('ended', x: 512, y: 504);
|
||||
|
||||
expect(harness.keys, isEmpty);
|
||||
});
|
||||
|
||||
test('short touch around a native directional key does not emit select', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft));
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('ended', x: 500, y: 500);
|
||||
|
||||
expect(harness.keys, isEmpty);
|
||||
});
|
||||
|
||||
test('swipe end does not also emit select', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('move', x: 380, y: 500);
|
||||
await harness.send('ended', x: 380, y: 500);
|
||||
|
||||
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||
});
|
||||
|
||||
test('deduplicates touch tap and click fallback select events', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('ended', x: 500, y: 500);
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keys, [LogicalKeyboardKey.enter]);
|
||||
|
||||
harness.advance(const Duration(milliseconds: 121));
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keys, [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_e');
|
||||
|
||||
expect(harness.keys, isEmpty);
|
||||
|
||||
harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select));
|
||||
harness.advance(const Duration(milliseconds: 121));
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keys, [LogicalKeyboardKey.enter]);
|
||||
});
|
||||
|
||||
test('recent directional input suppresses click fallback', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft));
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keys, isEmpty);
|
||||
|
||||
harness.advance(const Duration(milliseconds: 221));
|
||||
await harness.send('click_e');
|
||||
|
||||
expect(harness.keys, [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_e');
|
||||
|
||||
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
|
||||
});
|
||||
|
||||
test('cancelled touch does not emit select on a later ended message', () async {
|
||||
final harness = _Harness();
|
||||
|
||||
await harness.send('started', x: 500, y: 500);
|
||||
await harness.send('cancelled');
|
||||
await harness.send('ended', x: 500, y: 500);
|
||||
await harness.send('loc', x: 1, y: 0);
|
||||
|
||||
expect(harness.keys, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _Harness {
|
||||
DateTime now = DateTime(2026, 5, 5, 12);
|
||||
final List<LogicalKeyboardKey> keys = [];
|
||||
|
||||
late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService(
|
||||
simulateKeyPress: keys.add,
|
||||
scheduleFrame: () {},
|
||||
now: () => now,
|
||||
swipeThreshold: 100,
|
||||
);
|
||||
|
||||
Future<void> send(String type, {double x = 0, double y = 0}) {
|
||||
return service.handleMessage({'type': type, 'x': x, 'y': y});
|
||||
}
|
||||
|
||||
void advance(Duration duration) {
|
||||
now = now.add(duration);
|
||||
}
|
||||
}
|
||||
|
||||
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
-1
@@ -1 +1 @@
|
||||
3.41.6+1
|
||||
3.41.6+5
|
||||
|
||||
Reference in New Issue
Block a user