fix(tvos): stop Home nav auto-playing continue watching

close #1281
This commit is contained in:
edde746
2026-06-09 06:10:22 +02:00
parent 7de7802368
commit e02daa89b8
5 changed files with 248 additions and 3 deletions
+4
View File
@@ -14,6 +14,7 @@ import '../focus/key_event_utils.dart';
import '../utils/global_key_utils.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import '../services/apple_tv_remote_touch_service.dart';
import '../services/image_cache_service.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
@@ -1615,6 +1616,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
onNavigateToSidebar: _navigateToSidebar,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
backgroundBleedLeft: sidebarBleed,
selectSuppressionGestureSignal: PlatformDetector.isAppleTV()
? AppleTvRemoteTouchService.instance.touchActiveListenable
: null,
),
),
SideNavigationBleedBuilder(
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -44,6 +45,7 @@ class AppleTvRemoteTouchService {
bool _listening = false;
bool _nativeKeyHandlerRegistered = false;
bool _touchActive = false;
final ValueNotifier<bool> _touchActiveNotifier = ValueNotifier<bool>(false);
double _startX = 0;
double _startY = 0;
double _anchorX = 0;
@@ -83,6 +85,14 @@ class AppleTvRemoteTouchService {
Stream<AppleTvRemotePlayPauseAction> get playPauseActions => _playPauseController.stream;
/// Whether a Siri-remote touch gesture is currently in progress (finger down).
/// Cleared when the touch ends or cancels. tvOS-only; `false` elsewhere.
bool get isTouchActive => _touchActive;
/// Listenable mirror of [isTouchActive] so widgets can react when the active
/// touch gesture ends (used to extend Home-rail select suppression).
ValueListenable<bool> get touchActiveListenable => _touchActiveNotifier;
void start() {
if (_listening) return;
_channel.setMessageHandler(handleMessage);
@@ -179,6 +189,7 @@ class AppleTvRemoteTouchService {
void _startTouch(double x, double y) {
_touchActive = true;
_touchActiveNotifier.value = true;
_startX = x;
_startY = y;
_anchorX = x;
@@ -412,6 +423,7 @@ class AppleTvRemoteTouchService {
void _resetTouch() {
_touchActive = false;
_touchActiveNotifier.value = false;
_lastSwipeAxis = null;
_lastSwipeAt = null;
}
+54 -3
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import '../media/ids.dart';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -320,6 +321,13 @@ class TvBrowseRail extends StatefulWidget {
final double Function(MediaHub hub)? widePosterScaleForHub;
final double backgroundBleedLeft;
/// Optional signal that is `true` while an input gesture (e.g. a Siri-remote
/// touch) is in progress. When select-suppression is armed during an active
/// gesture, it is held until the gesture ends (finger lift) rather than the
/// short no-touch timeout — one activation per touch. Generic by design: no
/// platform/service coupling here.
final ValueListenable<bool>? selectSuppressionGestureSignal;
const TvBrowseRail({
super.key,
required this.hubs,
@@ -345,6 +353,7 @@ class TvBrowseRail extends StatefulWidget {
this.episodePosterModeForHub,
this.widePosterScaleForHub,
this.backgroundBleedLeft = 0,
this.selectSuppressionGestureSignal,
});
@override
@@ -353,7 +362,14 @@ class TvBrowseRail extends StatefulWidget {
class TvBrowseRailState extends State<TvBrowseRail> {
static const _longPressDuration = Duration(milliseconds: 500);
// No-touch fallback only: clear suppression even if no select key-up is seen
// (e.g. a held-key carry-over on a non-touch remote). Touch-driven clicks use
// the gesture path instead, which is bounded by the physical touch.
static const _selectSuppressionTimeout = Duration(milliseconds: 220);
// Touch path safety net only. The deterministic clear is the touch ending
// (finger lift); this guards solely against a dropped touch-end event and is
// generous enough never to fire mid-gesture in practice.
static const _selectSuppressionGestureBackstop = Duration(seconds: 3);
static const _navigationScrollDuration = Duration(milliseconds: 130);
static const _repeatNavigationScrollDuration = Duration(milliseconds: 65);
static const _scrollCatchUpViewportDistance = 2.5;
@@ -374,6 +390,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
double _sectionMaxScrollExtent = 0;
Timer? _longPressTimer;
Timer? _selectSuppressionTimer;
Timer? _selectSuppressionMaxTimer;
VoidCallback? _gestureSignalListener;
bool _isSelectKeyDown = false;
bool _longPressTriggered = false;
bool _suppressSelectUntilKeyUp = false;
@@ -391,9 +409,29 @@ class TvBrowseRailState extends State<TvBrowseRail> {
_resetLongPressState();
_suppressSelectUntilKeyUp = true;
_selectSuppressionTimer?.cancel();
_selectSuppressionTimer = Timer(_selectSuppressionTimeout, () {
_suppressSelectUntilKeyUp = false;
});
_selectSuppressionMaxTimer?.cancel();
_detachGestureSignalListener();
final gesture = widget.selectSuppressionGestureSignal;
if (gesture != null && gesture.value) {
// A Siri-remote touch is in progress. The stray select that would auto-play
// a Continue Watching item is delivered within this same uninterrupted
// touch: one physical press navigates Home, then bounces a second select
// mid-drag (#1281). Hold suppression until the finger lifts — one
// activation per touch, no time heuristic. The next observed select key-up
// also clears it; the backstop only guards against a dropped touch-end.
_gestureSignalListener = () {
if (!(widget.selectSuppressionGestureSignal?.value ?? false)) {
_clearSelectSuppression();
}
};
gesture.addListener(_gestureSignalListener!);
_selectSuppressionMaxTimer = Timer(_selectSuppressionGestureBackstop, _clearSelectSuppression);
} else {
// No touch in progress (held-key carry-over on a non-touch remote): clear
// on the next select key-up, with the short legacy safety timeout.
_selectSuppressionTimer = Timer(_selectSuppressionTimeout, _clearSelectSuppression);
}
}
@override
@@ -522,6 +560,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
void dispose() {
_longPressTimer?.cancel();
_selectSuppressionTimer?.cancel();
_selectSuppressionMaxTimer?.cancel();
_detachGestureSignalListener();
_focusNode.removeListener(_handleFocusChange);
_focusNode.dispose();
for (final controller in _scrollControllers.values) {
@@ -546,7 +586,18 @@ class TvBrowseRailState extends State<TvBrowseRail> {
void _clearSelectSuppression() {
_selectSuppressionTimer?.cancel();
_selectSuppressionTimer = null;
_selectSuppressionMaxTimer?.cancel();
_selectSuppressionMaxTimer = null;
_suppressSelectUntilKeyUp = false;
_detachGestureSignalListener();
}
void _detachGestureSignalListener() {
final listener = _gestureSignalListener;
if (listener != null) {
widget.selectSuppressionGestureSignal?.removeListener(listener);
_gestureSignalListener = null;
}
}
bool _hasTrailingFor(MediaHub hub) => _trailingFor(hub) != TvRailTrailing.none;
@@ -257,6 +257,32 @@ 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);
});
});
}
+152
View File
@@ -1694,6 +1694,158 @@ void main() {
expect(activations, 1);
});
testWidgets('without a gesture signal, suppression clears on the legacy safety timeout', (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();
// 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 = 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,
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 = 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,
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();