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

This commit is contained in:
edde746
2026-07-26 03:41:19 +02:00
parent c9543d4af0
commit 1b3c74550f
11 changed files with 111 additions and 651 deletions
+24
View File
@@ -71,6 +71,30 @@ void main() {
expect(shouldPass(isAppleTV: false), isFalse);
});
test('tvOS Menu policy transaction publishes only the settled navigation state', () {
var desired = false;
final published = <bool>[];
final publisher = TvosMenuPolicyPublisher(() => desired, published.add);
publisher.run(() {
desired = true;
publisher.update();
desired = false;
});
expect(published, [false]);
});
test('tvOS Menu policy publishes retained sidebar Home state immediately', () {
final desired = true;
final published = <bool>[];
final publisher = TvosMenuPolicyPublisher(() => desired, published.add);
publisher.update();
expect(published, [true]);
});
test('macOS physical Escape is reserved for native fullscreen only at root Home', () {
bool shouldHandle({
bool isMacOS = true,
@@ -134,119 +134,14 @@ void main() {
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
});
test('click events emit held select key down and up', () async {
final harness = _Harness();
await harness.send('started', x: 500, y: 500);
await harness.send('ended', x: 500, y: 500);
await harness.send('click_s');
await harness.send('click_e');
expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
harness.advance(const Duration(milliseconds: 121));
await harness.send('click_s');
await harness.send('click_e');
expect(harness.keyDowns, [LogicalKeyboardKey.enter, LogicalKeyboardKey.enter]);
expect(harness.keyUps, [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_s');
await harness.send('click_e');
expect(harness.keyDowns, isEmpty);
expect(harness.keyUps, isEmpty);
harness.service.handleNativeKeyEvent(_keyUp(LogicalKeyboardKey.select));
harness.advance(const Duration(milliseconds: 121));
await harness.send('click_s');
await harness.send('click_e');
expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
expect(harness.keyUps, [LogicalKeyboardKey.enter]);
});
test('native select during click fallback is consumed and releases synthetic select', () async {
test('legacy click messages do not synthesize 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]);
expect(harness.keys, isEmpty);
});
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();
harness.service.handleNativeKeyEvent(_keyDown(LogicalKeyboardKey.arrowLeft));
await harness.send('click_s');
await harness.send('click_e');
expect(harness.keyDowns, isEmpty);
expect(harness.keyUps, isEmpty);
harness.advance(const Duration(milliseconds: 221));
await harness.send('click_s');
await harness.send('click_e');
expect(harness.keyDowns, [LogicalKeyboardKey.enter]);
expect(harness.keyUps, [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_s');
await harness.send('click_e');
expect(harness.keys, [LogicalKeyboardKey.arrowLeft]);
expect(harness.keyDowns, isEmpty);
expect(harness.keyUps, isEmpty);
});
test('cancelled touch does not emit select on a later ended message', () async {
final harness = _Harness();
@@ -257,45 +152,15 @@ void main() {
expect(harness.keys, isEmpty);
});
test('isTouchActive and listenable track touch start and end', () async {
final harness = _Harness();
final seen = <bool>[];
harness.service.touchActiveListenable.addListener(() => seen.add(harness.service.isTouchActive));
expect(harness.service.isTouchActive, isFalse);
await harness.send('started', x: 500, y: 500);
expect(harness.service.isTouchActive, isTrue);
await harness.send('ended', x: 500, y: 500);
expect(harness.service.isTouchActive, isFalse);
expect(seen, [true, false]);
});
test('cancelled touch clears touch-active state', () async {
final harness = _Harness();
await harness.send('started', x: 500, y: 500);
expect(harness.service.isTouchActive, isTrue);
await harness.send('cancelled');
expect(harness.service.isTouchActive, isFalse);
});
});
}
class _Harness {
DateTime now = DateTime(2026, 5, 5, 12);
final List<LogicalKeyboardKey> keys = [];
final List<LogicalKeyboardKey> keyDowns = [];
final List<LogicalKeyboardKey> keyUps = [];
late final AppleTvRemoteTouchService service = AppleTvRemoteTouchService(
simulateKeyPress: keys.add,
simulateKeyDown: keyDowns.add,
simulateKeyUp: keyUps.add,
scheduleFrame: () {},
now: () => now,
swipeThreshold: 100,
@@ -310,12 +175,6 @@ class _Harness {
}
}
const _rawEnterKey = LogicalKeyboardKey(0x0d);
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,6 +1,7 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/gamepad_service.dart';
import 'package:universal_gamepad/universal_gamepad.dart';
void main() {
group('GamepadDuplicateInputGuard', () {
@@ -60,6 +61,23 @@ void main() {
expect(guard.shouldSuppressSyntheticKey(LogicalKeyboardKey.gameButtonB), isTrue);
});
});
test('tvOS engine exclusively owns standard navigation buttons', () {
for (final button in [
GamepadButton.dpadUp,
GamepadButton.dpadDown,
GamepadButton.dpadLeft,
GamepadButton.dpadRight,
GamepadButton.a,
GamepadButton.b,
]) {
expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: button), isTrue);
}
expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: GamepadButton.x), isFalse);
expect(isTvosEngineOwnedGamepadButton(isAppleTV: true, button: GamepadButton.leftShoulder), isFalse);
expect(isTvosEngineOwnedGamepadButton(isAppleTV: false, button: GamepadButton.a), isFalse);
});
}
KeyDownEvent _keyDown(LogicalKeyboardKey logicalKey) {
-206
View File
@@ -2041,212 +2041,6 @@ void main() {
expect(activations, 1);
});
testWidgets('suppresses transferred select activation until key up', (tester) async {
var activations = 0;
final person = testMediaItem(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(
focusMemory: focusMemory,
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('without a gesture signal, suppression clears on the legacy safety timeout', (tester) async {
var activations = 0;
final person = testMediaItem(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(
focusMemory: focusMemory,
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();
// With no touch gesture, suppression must not outlive the short safety
// timeout — a select after it elapses activates normally.
await tester.pump(const Duration(milliseconds: 300));
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(activations, 1);
});
testWidgets('an active touch gesture holds select suppression past the legacy window', (tester) async {
var activations = 0;
final gesture = ValueNotifier<bool>(true);
addTearDown(gesture.dispose);
final person = testMediaItem(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(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
selectSuppressionGestureSignal: gesture,
onActivateItem: (_, _) {
activations++;
return Future.value(true);
},
),
),
),
),
),
);
await tester.pump();
final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
railState.requestFocus();
railState.suppressSelectUntilKeyUp();
await tester.pump();
// Well past the legacy 220ms window, finger still down (gesture active): the
// stray same-gesture select (#1281) is still ignored.
await tester.pump(const Duration(milliseconds: 1000));
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(activations, 0);
// Once cleared, deliberate selects work again.
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(activations, 1);
});
testWidgets('ending the gesture clears select suppression before the backstop', (tester) async {
var activations = 0;
final gesture = ValueNotifier<bool>(true);
addTearDown(gesture.dispose);
final person = testMediaItem(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(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
selectSuppressionGestureSignal: gesture,
onActivateItem: (_, _) {
activations++;
return Future.value(true);
},
),
),
),
),
),
);
await tester.pump();
final railState = tester.state<TvBrowseRailState>(find.byType(TvBrowseRail));
railState.requestFocus();
railState.suppressSelectUntilKeyUp();
await tester.pump();
// Suppression holds while the gesture is active, past the legacy window.
await tester.pump(const Duration(milliseconds: 1000));
// Finger lifts -> gesture ends -> suppression clears immediately, well before
// the safety backstop.
gesture.value = false;
await tester.pump();
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();