fix(tvos): stabilize Siri Remote input

This commit is contained in:
edde746
2026-05-05 23:49:31 +02:00
parent 94dbae0af9
commit 18a5041334
7 changed files with 480 additions and 24 deletions
+4
View File
@@ -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());
+42 -20
View File
@@ -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);
}
}
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
3.41.6+1
3.41.6+5