fix(tvos): make remote input lifecycle engine-owned
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user