@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user