diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 80537126..ce881a8b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 onNavigateToSidebar: _navigateToSidebar, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, backgroundBleedLeft: sidebarBleed, + selectSuppressionGestureSignal: PlatformDetector.isAppleTV() + ? AppleTvRemoteTouchService.instance.touchActiveListenable + : null, ), ), SideNavigationBleedBuilder( diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index 2846c8a3..249e0d9c 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -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 _touchActiveNotifier = ValueNotifier(false); double _startX = 0; double _startY = 0; double _anchorX = 0; @@ -83,6 +85,14 @@ class AppleTvRemoteTouchService { Stream 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 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; } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 6e404c11..abf038bf 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -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? 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 { 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 { 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 { _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 { 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 { 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; diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart index 61ba9112..a8e248b7 100644 --- a/test/services/apple_tv_remote_touch_service_test.dart +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -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 = []; + 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); + }); }); } diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index e745c213..e13b5c4d 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -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( + 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(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(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( + 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(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(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( + 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(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();