From fe79817e76bf65555827a99e5927fdaa33b75d55 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:46:31 +0200 Subject: [PATCH] fix(tvos): stop a single Siri Remote flick moving focus two steps Touch travel banked during the swipe repeat cooldown was released as a second focus step by the first post-cooldown move frame, even when the finger had stopped or was lifting. Re-anchor the swipe delta on every frame inside the cooldown so a discrete flick emits exactly one step while a sustained drag keeps repeating. close #1756 --- .../apple_tv_remote_touch_service.dart | 14 ++++++- .../apple_tv_remote_touch_service_test.dart | 40 ++++++++++++++++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/lib/services/apple_tv_remote_touch_service.dart b/lib/services/apple_tv_remote_touch_service.dart index 4ee95ee0..30eb1c63 100644 --- a/lib/services/apple_tv_remote_touch_service.dart +++ b/lib/services/apple_tv_remote_touch_service.dart @@ -172,12 +172,19 @@ class AppleTvRemoteTouchService { final deltaX = _anchorX - x; final deltaY = _anchorY - y; - final axis = _resolveSwipeAxis(x: x, y: y, deltaX: deltaX, deltaY: deltaY); - if (axis == null) return; final now = _now(); final lastSwipeAt = _lastSwipeAt; if (lastSwipeAt != null && now.difference(lastSwipeAt) < swipeRepeatInterval) { + // Travel during the repeat cooldown never counts toward the next step: + // re-anchor on every frame so a fast flick's deceleration tail is + // discarded instead of banked. Without this, the first post-cooldown + // move frame — even a stationary or lift-drift one — released the + // banked delta as a second focus step for a single intentional swipe. + // A deliberate continuous drag still repeats because it covers a fresh + // swipe threshold after each cooldown expires. + _anchorX = x; + _anchorY = y; final age = now.difference(lastSwipeAt).inMilliseconds; _log( 'suppress swipe reason=repeat-cooldown age=${age}ms dx=${_formatDouble(deltaX)} dy=${_formatDouble(deltaY)}', @@ -185,6 +192,9 @@ class AppleTvRemoteTouchService { return; } + final axis = _resolveSwipeAxis(x: x, y: y, deltaX: deltaX, deltaY: deltaY); + if (axis == null) return; + final logicalKey = axis == _SwipeAxis.horizontal ? (deltaX >= 0 ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight) : (deltaY >= 0 ? LogicalKeyboardKey.arrowUp : LogicalKeyboardKey.arrowDown); diff --git a/test/services/apple_tv_remote_touch_service_test.dart b/test/services/apple_tv_remote_touch_service_test.dart index abc7520e..e326154e 100644 --- a/test/services/apple_tv_remote_touch_service_test.dart +++ b/test/services/apple_tv_remote_touch_service_test.dart @@ -6,7 +6,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('AppleTvRemoteTouchService', () { - test('emits repeated horizontal swipes only after the repeat interval', () async { + test('a single fast flick emits exactly one swipe', () async { final harness = _Harness(); await harness.send('started', x: 500, y: 500); @@ -15,8 +15,44 @@ void main() { expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + // The flick's tail travel landed inside the repeat cooldown and must be + // discarded: a near-stationary frame after the cooldown expires must not + // release it as a second focus step. harness.advance(const Duration(milliseconds: 141)); - await harness.send('move', x: 260, y: 490); + await harness.send('move', x: 259, y: 490); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + }); + + test('sub-threshold cooldown travel plus lift drift does not fire a second swipe', () async { + final harness = _Harness(); + + await harness.send('started', x: 500, y: 500); + await harness.send('move', x: 390, y: 500); + // 80pt tail inside the cooldown: below threshold, but banked it would + // combine with the 70pt lift drift below to cross the 100pt threshold. + await harness.send('move', x: 310, y: 500); + + harness.advance(const Duration(milliseconds: 141)); + await harness.send('move', x: 240, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + }); + + test('a sustained drag keeps repeating after each repeat interval', () async { + final harness = _Harness(); + + await harness.send('started', x: 900, y: 500); + await harness.send('move', x: 780, y: 500); + + expect(harness.keys, [LogicalKeyboardKey.arrowLeft]); + + harness.advance(const Duration(milliseconds: 70)); + await harness.send('move', x: 700, y: 500); + + // A full fresh threshold is covered after the cooldown expires. + harness.advance(const Duration(milliseconds: 71)); + await harness.send('move', x: 580, y: 500); expect(harness.keys, [LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowLeft]); });