From fb0613e3db743df394a988efa13fe598d0989d7f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:04:03 +0200 Subject: [PATCH] feat(player): toggle playback on a two-finger tap without raising the chrome A touch viewer had to raise the chrome to pause, which dims the picture and covers the subtitle line they were trying to finish reading. A two-finger tap now toggles playback with the chrome left down, so the frame that pauses is the frame that was on screen. It fires the moment the chord resolves, in every player state. The two-finger double tap no longer resets the video zoom. Keeping it would mean holding this toggle back for the double-tap window before acting, and pausing late is pausing on the wrong frame. Zoom reset stays in the video settings sheet, its presets and the keyboard shortcut, and pinching back to 100% now snaps exactly within three percent so touch has a one-gesture path too. Both chord actions share _mobileTouchGesturesAllowed, so the chord is inert under screen lock, in PiP and while the content strip is open; the zoom reset previously fired straight through a locked screen. close #1505 --- lib/screens/video_player/parts/build.dart | 6 +- lib/services/video_filter_manager.dart | 23 + ...acker.dart => two_finger_tap_tracker.dart} | 73 +--- .../video_controls/parts/playback_input.dart | 37 +- .../video_controls/video_controls.dart | 4 +- scripts/check_analyzer.dart | 13 - test/services/video_filter_manager_test.dart | 33 ++ ...ls_two_finger_double_tap_tracker_test.dart | 71 ---- .../video_controls_two_finger_tap_test.dart | 396 ++++++++++++++++++ ..._controls_two_finger_tap_tracker_test.dart | 141 +++++++ 10 files changed, 645 insertions(+), 152 deletions(-) rename lib/widgets/video_controls/helpers/{two_finger_double_tap_tracker.dart => two_finger_tap_tracker.dart} (57%) delete mode 100644 test/widgets/video_controls_two_finger_double_tap_tracker_test.dart create mode 100644 test/widgets/video_controls_two_finger_tap_test.dart create mode 100644 test/widgets/video_controls_two_finger_tap_tracker_test.dart diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index d33b886c..184050c2 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -186,7 +186,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { final startZoom = _pinchStartZoomScale; final filterManager = _videoFilterManager; if (!_isPinchZooming || startZoom == null || filterManager == null) return; - final nextZoomScale = VideoFilterManager.normalizeZoomScale(startZoom * details.scale); + // Snap through 100% so pinching back undoes a zoom exactly, which is + // the touch path to an unzoomed picture (#1505). + final nextZoomScale = VideoFilterManager.normalizeZoomScale( + VideoFilterManager.snapPinchZoomScale(startZoom * details.scale), + ); if (!_pinchZoomChanged) { if ((details.scale - 1.0).abs() <= _pinchZoomActivationThreshold) { diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 7e1b3f9a..dc9237b9 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -21,6 +21,29 @@ class VideoFilterManager { static const double maxZoomScale = 2.0; static const double zoomStep = 0.01; + /// How close a pinch has to get to 100%, in whole percent, before it snaps + /// there exactly. + /// + /// [normalizeZoomScale] rounds to whole percent, so an unaided pinch has to + /// land inside half a percent of 1.0 to undo itself — in practice it leaves + /// the picture at 99% or 101% and the viewer cannot tell why it still looks + /// cropped. A detent makes the pinch a reliable inverse of itself. + /// + /// Deliberately applied to the pinch handler only, never inside + /// [normalizeZoomScale]: [zoomStep] is 1%, so a global detent would trap the + /// keyboard and slider paths at 1.0 with no way to step out. + static const int pinchZoomResetDetentPercent = 3; + + /// Snaps a pinch-derived [scale] to exactly 1.0 inside the detent. + /// + /// Compared in whole percent like [normalizeZoomScale], not in raw doubles: + /// `(0.97 - 1.0).abs()` is 0.030000000000000027, so a float comparison against + /// a 0.03 band drops the very boundary it is meant to include. + static double snapPinchZoomScale(double scale) { + final percent = (scale * 100).round(); + return (percent - 100).abs() <= pinchZoomResetDetentPercent ? 1.0 : scale; + } + final Player player; /// BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) diff --git a/lib/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart b/lib/widgets/video_controls/helpers/two_finger_tap_tracker.dart similarity index 57% rename from lib/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart rename to lib/widgets/video_controls/helpers/two_finger_tap_tracker.dart index 9e71db89..33269074 100644 --- a/lib/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart +++ b/lib/widgets/video_controls/helpers/two_finger_tap_tracker.dart @@ -1,29 +1,36 @@ import 'dart:ui' show Offset; -import 'package:flutter/gestures.dart' show kDoubleTapSlop, kDoubleTapTimeout, kDoubleTapTouchSlop; +import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapTouchSlop; -class TwoFingerDoubleTapTracker { - TwoFingerDoubleTapTracker({ +/// Detects a two-finger tap on the player surface from raw pointer events. +/// +/// The player deliberately avoids Flutter's multi-pointer recognizers here: the +/// root `Listener` is translucent and must observe the chord without entering +/// the gesture arena, so the single-finger tap layers below keep working. +/// +/// A candidate arms when exactly two touches are down and completes when the +/// last one lifts within [tapTimeout] having moved less than [tapSlop]. A third +/// finger or any slop-exceeding movement invalidates it — a pinch is not a tap. +/// +/// There is deliberately no double-tap notion here. The chord's only meaning is +/// "toggle playback", and it fires the moment it resolves so the viewer keeps +/// the frame they aimed at (#1505); recognising a pair would mean holding the +/// toggle back for [kDoubleTapTimeout] first. +class TwoFingerTapTracker { + TwoFingerTapTracker({ DateTime Function()? now, this.tapTimeout = kDoubleTapTimeout, - this.doubleTapTimeout = kDoubleTapTimeout, this.tapSlop = kDoubleTapTouchSlop, - this.doubleTapSlop = kDoubleTapSlop, }) : _now = now ?? DateTime.now; final DateTime Function() _now; final Duration tapTimeout; - final Duration doubleTapTimeout; final double tapSlop; - final double doubleTapSlop; final Map _activeTouches = {}; DateTime? _candidateStartTime; - Offset? _candidateFocalPoint; bool _candidateActive = false; bool _candidateInvalid = false; - DateTime? _lastTapTime; - Offset? _lastTapFocalPoint; bool get isChordActive => _activeTouches.length > 1 || _candidateActive; @@ -34,7 +41,6 @@ class TwoFingerDoubleTapTracker { _candidateActive = true; _candidateInvalid = false; _candidateStartTime = _earliestActiveDownTime(); - _candidateFocalPoint = _activeFocalPoint(); } else if (_activeTouches.length > 2) { _candidateInvalid = true; } @@ -50,6 +56,7 @@ class TwoFingerDoubleTapTracker { } } + /// Returns true when this lift completes a two-finger tap. bool pointerUp(int pointer, Offset position) { final touch = _activeTouches[pointer]; if (touch == null) return false; @@ -63,17 +70,11 @@ class TwoFingerDoubleTapTracker { if (_activeTouches.isNotEmpty) return false; final startTime = _candidateStartTime; - final focalPoint = _candidateFocalPoint; final isTap = - _candidateActive && - !_candidateInvalid && - startTime != null && - focalPoint != null && - _now().difference(startTime) <= tapTimeout; + _candidateActive && !_candidateInvalid && startTime != null && _now().difference(startTime) <= tapTimeout; _clearCandidate(); - if (!isTap) return false; - return _recordTwoFingerTap(focalPoint); + return isTap; } void pointerCancel(int pointer) { @@ -82,46 +83,12 @@ class TwoFingerDoubleTapTracker { if (_activeTouches.isEmpty) _clearCandidate(); } - void resetSeries() { - _lastTapTime = null; - _lastTapFocalPoint = null; - } - DateTime _earliestActiveDownTime() { return _activeTouches.values.map((touch) => touch.downTime).reduce((a, b) => a.isBefore(b) ? a : b); } - Offset _activeFocalPoint() { - var sum = Offset.zero; - for (final touch in _activeTouches.values) { - sum += touch.current; - } - return sum / _activeTouches.length.toDouble(); - } - - bool _recordTwoFingerTap(Offset focalPoint) { - final now = _now(); - final lastTapTime = _lastTapTime; - final lastTapFocalPoint = _lastTapFocalPoint; - final isDoubleTap = - lastTapTime != null && - lastTapFocalPoint != null && - now.difference(lastTapTime) <= doubleTapTimeout && - (focalPoint - lastTapFocalPoint).distance <= doubleTapSlop; - - if (isDoubleTap) { - resetSeries(); - return true; - } - - _lastTapTime = now; - _lastTapFocalPoint = focalPoint; - return false; - } - void _clearCandidate() { _candidateStartTime = null; - _candidateFocalPoint = null; _candidateActive = false; _candidateInvalid = false; } diff --git a/lib/widgets/video_controls/parts/playback_input.dart b/lib/widgets/video_controls/parts/playback_input.dart index b419757c..adb0f243 100644 --- a/lib/widgets/video_controls/parts/playback_input.dart +++ b/lib/widgets/video_controls/parts/playback_input.dart @@ -270,15 +270,15 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { void _handleTouchPointerDown(PointerDownEvent event) { if (event.kind != PointerDeviceKind.touch) return; - _twoFingerDoubleTapTracker.pointerDown(event.pointer, event.position); - if (_twoFingerDoubleTapTracker.isChordActive) { + _twoFingerTapTracker.pointerDown(event.pointer, event.position); + if (_twoFingerTapTracker.isChordActive) { _suppressTouchTaps(); _cancelEdgeAdjustmentGesture(); return; } final hit = _edgeAdjustmentSurfaceHit(event.position); _handleEdgeAdjustmentEvent( - _edgeAdjustmentGesturesAllowed && hit != null + _mobileTouchGesturesAllowed && hit != null ? _edgeAdjustmentTracker.pointerDown(event.pointer, hit.position, hit.size) : const MobileEdgeAdjustmentEvent.none(), ); @@ -286,13 +286,13 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { void _handleTouchPointerMove(PointerMoveEvent event) { if (event.kind != PointerDeviceKind.touch) return; - _twoFingerDoubleTapTracker.pointerMove(event.pointer, event.position); - if (_twoFingerDoubleTapTracker.isChordActive) { + _twoFingerTapTracker.pointerMove(event.pointer, event.position); + if (_twoFingerTapTracker.isChordActive) { _suppressTouchTaps(); _cancelEdgeAdjustmentGesture(); return; } - if (!_edgeAdjustmentGesturesAllowed) { + if (!_mobileTouchGesturesAllowed) { _cancelEdgeAdjustmentGesture(); return; } @@ -306,21 +306,34 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState { void _handleTouchPointerUp(PointerUpEvent event) { if (event.kind != PointerDeviceKind.touch) return; - final isResetGesture = _twoFingerDoubleTapTracker.pointerUp(event.pointer, event.position); + final isTwoFingerTap = _twoFingerTapTracker.pointerUp(event.pointer, event.position); final hit = _edgeAdjustmentSurfaceHit(event.position); _handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.pointerUp(event.pointer, hit?.position ?? event.localPosition)); - if (_isTouchTapSuppressed || isResetGesture) _suppressTouchTaps(); - if (isResetGesture) widget.onResetVideoZoom?.call(); + if (_isTouchTapSuppressed || isTwoFingerTap) _suppressTouchTaps(); + // Toggle playback with the chrome left down (#1505), the moment the chord + // resolves and in every player state. Deliberately no _toggleControls()/ + // chromeController.show(): covering the frame the viewer paused to read is + // the problem this gesture exists to solve. The centred transport disc still + // confirms the command via _announceTransportCommand, which only renders + // while the chrome is hidden. + // + // The chord previously also reset the video zoom on a double tap. That was + // dropped rather than deferred: recognising a pair means holding this toggle + // back for kDoubleTapTimeout, and pausing late is pausing on the wrong + // frame. Zoom reset lives in the video settings sheet, its presets, the + // keyboard shortcut, and — for touch — pinching back through the 100% detent + // in VideoFilterManager.snapPinchZoomScale. + if (isTwoFingerTap && _mobileTouchGesturesAllowed) unawaited(_playOrPause()); } void _handleTouchPointerCancel(PointerCancelEvent event) { if (event.kind != PointerDeviceKind.touch) return; - _twoFingerDoubleTapTracker.pointerCancel(event.pointer); + _twoFingerTapTracker.pointerCancel(event.pointer); _handleEdgeAdjustmentEvent(_edgeAdjustmentTracker.pointerCancel(event.pointer)); - if (_twoFingerDoubleTapTracker.isChordActive) _suppressTouchTaps(); + if (_twoFingerTapTracker.isChordActive) _suppressTouchTaps(); } - bool get _edgeAdjustmentGesturesAllowed { + bool get _mobileTouchGesturesAllowed { return PlatformDetector.isMobile(context) && !PlatformDetector.isTV() && !_isScreenLocked && diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 1963a823..a3e5399b 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -76,7 +76,7 @@ import '../../focus/input_mode_tracker.dart'; import 'models/track_controls_state.dart'; import 'widgets/double_tap_feedback.dart'; import 'helpers/mobile_edge_adjustment_tracker.dart'; -import 'helpers/two_finger_double_tap_tracker.dart'; +import 'helpers/two_finger_tap_tracker.dart'; import 'widgets/linux_keep_alive.dart'; import 'widgets/mobile_edge_adjustment_indicator.dart'; import 'widgets/mobile_skip_zones.dart'; @@ -687,7 +687,7 @@ class _PlexVideoControlsState extends State // Deferred lone-tap action for the skip zones, and the pairing window itself: // while it is active the tap that started it can still become a double tap. Timer? _singleTapTimer; - final TwoFingerDoubleTapTracker _twoFingerDoubleTapTracker = TwoFingerDoubleTapTracker(); + final TwoFingerTapTracker _twoFingerTapTracker = TwoFingerTapTracker(); final MobileEdgeAdjustmentTracker _edgeAdjustmentTracker = MobileEdgeAdjustmentTracker(); final DeviceAdjustmentService _deviceAdjustmentService = DeviceAdjustmentService.instance; DateTime? _suppressTouchTapUntil; diff --git a/scripts/check_analyzer.dart b/scripts/check_analyzer.dart index 40e3a33e..4f96b065 100644 --- a/scripts/check_analyzer.dart +++ b/scripts/check_analyzer.dart @@ -15,19 +15,6 @@ final _allowedDiagnostics = { length: 4, message: "Missing an 'await' for the 'Future' computed by this expression.", ), - const AnalyzerDiagnostic( - severity: 'INFO', - type: 'HINT', - code: 'UNNECESSARY_IMPORT', - path: 'test/widgets/video_controls_two_finger_double_tap_tracker_test.dart', - line: 1, - column: 8, - length: 31, - message: - "The import of 'package:flutter/material.dart' is unnecessary because " - 'all of the used elements are also provided by the import of ' - "'package:flutter_test/flutter_test.dart'.", - ), }; Future main() async { diff --git a/test/services/video_filter_manager_test.dart b/test/services/video_filter_manager_test.dart index d861a52b..2b67e75a 100644 --- a/test/services/video_filter_manager_test.dart +++ b/test/services/video_filter_manager_test.dart @@ -145,6 +145,39 @@ void main() { expect(aspectWrites, hasLength(1)); expect(double.parse(aspectWrites.single.value), closeTo(1.0, 0.0001)); }); + + // Pinching back is the touch path to an unzoomed picture (#1505). Without a + // detent, normalizeZoomScale's whole-percent rounding means an unaided pinch + // leaves the frame at 99% or 101% and the viewer cannot tell why it still + // looks cropped. + group('snapPinchZoomScale', () { + test('snaps to exactly 1.0 inside the detent', () { + for (final scale in [0.97, 0.99, 1.0, 1.01, 1.03]) { + expect(VideoFilterManager.snapPinchZoomScale(scale), 1.0, reason: '$scale is within the detent'); + } + }); + + test('leaves scales outside the detent alone', () { + for (final scale in [0.5, 0.9, 0.96, 1.04, 1.1, 2.0]) { + expect(VideoFilterManager.snapPinchZoomScale(scale), scale, reason: '$scale is outside the detent'); + } + }); + + test('does not swallow the neighbouring zoom presets', () { + // The sheet offers 0.9 and 1.1 either side of 100%; a detent that ate + // them would make those presets unreachable by pinch. + expect(VideoFilterManager.snapPinchZoomScale(0.9), 0.9); + expect(VideoFilterManager.snapPinchZoomScale(1.1), 1.1); + }); + + test('keeps the 1% keyboard step escapable', () { + // zoomStep is 1%, inside the detent — proof the detent is confined to the + // pinch path and never reaches normalizeZoomScale, or zoom-in from 100% + // could never leave 100%. + final stepped = VideoFilterManager.normalizeZoomScale(1.0 + VideoFilterManager.zoomStep); + expect(stepped, closeTo(1.01, 0.0001)); + }); + }); } class _RecordingPlayer implements Player { diff --git a/test/widgets/video_controls_two_finger_double_tap_tracker_test.dart b/test/widgets/video_controls_two_finger_double_tap_tracker_test.dart deleted file mode 100644 index 6891332a..00000000 --- a/test/widgets/video_controls_two_finger_double_tap_tracker_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/widgets/video_controls/helpers/two_finger_double_tap_tracker.dart'; - -void main() { - late DateTime now; - late TwoFingerDoubleTapTracker tracker; - - void advance(Duration duration) { - now = now.add(duration); - } - - setUp(() { - now = DateTime(2026); - tracker = TwoFingerDoubleTapTracker(now: () => now); - }); - - test('detects two two-finger taps as double tap', () { - tracker.pointerDown(1, const Offset(100, 100)); - tracker.pointerDown(2, const Offset(140, 100)); - expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse); - expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); - - advance(const Duration(milliseconds: 120)); - - tracker.pointerDown(3, const Offset(102, 102)); - tracker.pointerDown(4, const Offset(142, 102)); - expect(tracker.pointerUp(3, const Offset(102, 102)), isFalse); - expect(tracker.pointerUp(4, const Offset(142, 102)), isTrue); - }); - - test('does not detect one-finger double tap', () { - tracker.pointerDown(1, const Offset(100, 100)); - expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse); - - advance(const Duration(milliseconds: 120)); - - tracker.pointerDown(2, const Offset(100, 100)); - expect(tracker.pointerUp(2, const Offset(100, 100)), isFalse); - }); - - test('movement invalidates a candidate two-finger tap', () { - tracker.pointerDown(1, const Offset(100, 100)); - tracker.pointerDown(2, const Offset(140, 100)); - tracker.pointerMove(1, const Offset(140, 140)); - expect(tracker.pointerUp(1, const Offset(140, 140)), isFalse); - expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); - - advance(const Duration(milliseconds: 120)); - - tracker.pointerDown(3, const Offset(100, 100)); - tracker.pointerDown(4, const Offset(140, 100)); - expect(tracker.pointerUp(3, const Offset(100, 100)), isFalse); - expect(tracker.pointerUp(4, const Offset(140, 100)), isFalse); - }); - - test('third finger invalidates a candidate two-finger tap', () { - tracker.pointerDown(1, const Offset(100, 100)); - tracker.pointerDown(2, const Offset(140, 100)); - tracker.pointerDown(3, const Offset(180, 100)); - expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse); - expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); - expect(tracker.pointerUp(3, const Offset(180, 100)), isFalse); - - advance(const Duration(milliseconds: 120)); - - tracker.pointerDown(4, const Offset(100, 100)); - tracker.pointerDown(5, const Offset(140, 100)); - expect(tracker.pointerUp(4, const Offset(100, 100)), isFalse); - expect(tracker.pointerUp(5, const Offset(140, 100)), isFalse); - }); -} diff --git a/test/widgets/video_controls_two_finger_tap_test.dart b/test/widgets/video_controls_two_finger_tap_test.dart new file mode 100644 index 00000000..57fef477 --- /dev/null +++ b/test/widgets/video_controls_two_finger_tap_test.dart @@ -0,0 +1,396 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:provider/provider.dart'; + +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/video_volume_controller.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/watch_together/providers/watch_together_provider.dart'; +import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; +import 'package:plezy/widgets/video_controls/video_controls.dart'; +import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart'; + +import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; +import '../test_helpers/theme.dart'; + +/// Issue #1505: on touch devices a two-finger tap toggles playback and leaves +/// the chrome down, so the frame the viewer paused to read stays uncovered. +/// +/// The gesture takes over the two-finger chord that previously reset the video +/// zoom on a double tap. The two cannot coexist — a two-finger double tap is +/// two two-finger taps, so recognising a pair means holding the toggle back for +/// kDoubleTapTimeout first, and pausing late is pausing on the wrong frame. The +/// reset binding was therefore dropped, not deferred: the tap fires the moment +/// it resolves, in every player state including while zoomed, and zoom reset +/// lives in the settings sheet, the keyboard shortcut, and pinching back +/// through the 100% detent. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _RecordingPlayer player; + late PlayerChromeController chrome; + late PlayerToastController toast; + late VideoVolumeController volume; + late PlaybackStateProvider playbackState; + late WatchTogetherProvider watchTogether; + late AppDatabase database; + late List transportCommands; + late int zoomResets; + + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + await initializeDateFormatting('en'); + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.seekTimeSmall, 10); + + // Phone layout: the touch pointer pipeline is wired only when + // PlatformDetector.isMobile(context) && !PlatformDetector.isTV(). + TvDetectionService.debugSetAppleTVOverride(false); + PlatformDetector.debugSetIsDesktopOSOverride(false); + + database = AppDatabase.forTesting(NativeDatabase.memory()); + player = _RecordingPlayer(); + chrome = PlayerChromeController(); + toast = PlayerToastController(); + volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100); + playbackState = PlaybackStateProvider(); + watchTogether = WatchTogetherProvider(); + transportCommands = []; + zoomResets = 0; + }); + + tearDown(() async { + TvDetectionService.debugSetAppleTVOverride(null); + PlatformDetector.debugSetIsDesktopOSOverride(null); + volume.dispose(); + playbackState.dispose(); + watchTogether.dispose(); + chrome.dispose(); + toast.dispose(); + await database.close(); + }); + + const surface = Size(800, 600); + + Future pumpControls( + WidgetTester tester, { + bool wireTransportCallback = true, + double videoZoomScale = 1.0, + bool canControl = true, + bool startPaused = false, + }) async { + if (startPaused) await player.pause(); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: database), + ChangeNotifierProvider.value(value: playbackState), + ChangeNotifierProvider.value(value: watchTogether), + ], + child: MaterialApp( + theme: ThemeData(platform: TargetPlatform.android, extensions: const [testMonoTokens]), + home: Scaffold( + body: SizedBox( + width: surface.width, + height: surface.height, + child: PlexVideoControls( + player: player, + volumeController: volume, + metadata: testMediaItem(id: 'two-finger-tap'), + toastController: toast, + chromeController: chrome, + canNavigateMediaItems: false, + canControl: canControl, + videoZoomScale: videoZoomScale, + onResetVideoZoom: () => zoomResets++, + // Mirrors the screen wiring at video_player/parts/build.dart:311. + // _handleControlsTransport announces every accepted command, so + // recording one here is recording the transport disc the viewer + // gets — the gesture is silent chrome, not silent playback. + onPlayPauseRequested: wireTransportCallback + ? (command) async { + transportCommands.add(command); + await switch (command) { + TransportCommand.play => player.play(), + TransportCommand.pause => player.pause(), + TransportCommand.toggle => player.playOrPause(), + }; + } + : null, + ), + ), + ), + ), + ), + ); + await tester.pump(); + // Every case starts from hidden chrome — the state the issue is about. + chrome.hide(); + chrome.markControlsHidden(); + await tester.pump(); + expect(chrome.controlsVisible, isFalse); + } + + Offset centreOf(WidgetTester tester) => tester.getRect(find.byType(PlexVideoControls)).center; + + /// Two fingers down, then both up — the production gesture. + Future twoFingerTap(WidgetTester tester, {Offset? at, double spread = 40}) async { + final origin = at ?? centreOf(tester); + final first = await tester.startGesture(origin - Offset(spread / 2, 0), pointer: _nextPointer()); + final second = await tester.startGesture(origin + Offset(spread / 2, 0), pointer: _nextPointer()); + await first.up(); + await second.up(); + await tester.pump(); + } + + Future settle(WidgetTester tester) async { + chrome.cancelAutoHide(); + toast.hide(); + await tester.pumpWidget(const SizedBox.shrink()); + } + + testWidgets('a two-finger tap pauses without raising the chrome', (tester) async { + await pumpControls(tester); + expect(player.state.playing, isTrue); + + await twoFingerTap(tester); + + expect(transportCommands, [TransportCommand.toggle]); + expect(player.state.playing, isFalse); + expect(chrome.controlsVisible, isFalse, reason: 'the paused frame must stay uncovered'); + + await settle(tester); + }); + + testWidgets('a second two-finger tap resumes — no pair is ever swallowed', (tester) async { + await pumpControls(tester); + + await twoFingerTap(tester); + expect(player.state.playing, isFalse); + + await twoFingerTap(tester); + + expect(transportCommands, [TransportCommand.toggle, TransportCommand.toggle]); + expect(player.state.playing, isTrue); + expect(chrome.controlsVisible, isFalse); + + await settle(tester); + }); + + testWidgets('a zoomed tap toggles immediately, with no waiting period', (tester) async { + await pumpControls(tester, videoZoomScale: 1.6); + + await twoFingerTap(tester); + + expect(transportCommands, [TransportCommand.toggle], reason: 'zoom must not delay or disable the gesture'); + expect(player.state.playing, isFalse); + expect(chrome.controlsVisible, isFalse); + + await settle(tester); + }); + + testWidgets('a zoomed tap dispatches nothing further once the double-tap window lapses', (tester) async { + await pumpControls(tester, videoZoomScale: 1.6); + + await twoFingerTap(tester); + await tester.pump(const Duration(milliseconds: 400)); + + expect(transportCommands, [TransportCommand.toggle], reason: 'no deferred action may be left pending'); + + await settle(tester); + }); + + testWidgets('a two-finger double tap never resets the zoom', (tester) async { + await pumpControls(tester, videoZoomScale: 1.6); + + await twoFingerTap(tester); + await twoFingerTap(tester); + + expect(zoomResets, 0, reason: 'the chord is transport-only; reset lives in the sheet, keys and pinch'); + expect(transportCommands, [TransportCommand.toggle, TransportCommand.toggle]); + expect(player.state.playing, isTrue, reason: 'two toggles return to the starting state'); + + await settle(tester); + }); + + testWidgets('a paused viewer is not resumed by anything but their own tap', (tester) async { + await pumpControls(tester, videoZoomScale: 1.6, startPaused: true); + + await twoFingerTap(tester); + + expect(player.state.playing, isTrue, reason: 'one tap toggles, from either direction'); + expect(transportCommands, [TransportCommand.toggle]); + expect(zoomResets, 0); + + await settle(tester); + }); + + testWidgets('a two-finger double tap while unzoomed does not touch the zoom', (tester) async { + await pumpControls(tester); + + await twoFingerTap(tester); + await twoFingerTap(tester); + + expect(zoomResets, 0, reason: 'resetting 100% to 100% would only flash a pointless zoom toast'); + + await settle(tester); + }); + + testWidgets('without a transport callback the gesture drives the player directly', (tester) async { + await pumpControls(tester, wireTransportCallback: false); + + await twoFingerTap(tester); + + expect(transportCommands, isEmpty); + expect(player.state.playing, isFalse); + expect(chrome.controlsVisible, isFalse); + + await settle(tester); + }); + + testWidgets('a one-finger tap still toggles the chrome instead of playback', (tester) async { + await pumpControls(tester); + + await tester.tapAt(centreOf(tester)); + await tester.pump(const Duration(milliseconds: 400)); + + expect(transportCommands, isEmpty); + expect(player.state.playing, isTrue); + expect(chrome.controlsVisible, isTrue); + + await settle(tester); + }); + + testWidgets('a three-finger tap does nothing', (tester) async { + await pumpControls(tester); + final origin = centreOf(tester); + + final a = await tester.startGesture(origin - const Offset(40, 0), pointer: _nextPointer()); + final b = await tester.startGesture(origin, pointer: _nextPointer()); + final c = await tester.startGesture(origin + const Offset(40, 0), pointer: _nextPointer()); + await a.up(); + await b.up(); + await c.up(); + await tester.pump(); + + expect(transportCommands, isEmpty); + expect(player.state.playing, isTrue); + expect(chrome.controlsVisible, isFalse); + + await settle(tester); + }); + + testWidgets('a pinch does not toggle playback', (tester) async { + await pumpControls(tester); + final origin = centreOf(tester); + + final first = await tester.startGesture(origin - const Offset(20, 0), pointer: _nextPointer()); + final second = await tester.startGesture(origin + const Offset(20, 0), pointer: _nextPointer()); + await first.moveTo(origin - const Offset(140, 0)); + await second.moveTo(origin + const Offset(140, 0)); + await first.up(); + await second.up(); + await tester.pump(); + + expect(transportCommands, isEmpty); + expect(player.state.playing, isTrue); + + await settle(tester); + }); + + testWidgets('the gesture is inert while the content strip is open', (tester) async { + await pumpControls(tester); + // Opening the strip forces the chrome visible, so this spot is chosen to + // miss every control in both states: the differential below is only + // meaningful because the same point toggles playback with the strip shut. + final rect = tester.getRect(find.byType(PlexVideoControls)); + final clearOfControls = Offset(rect.left + rect.width * 0.08, rect.top + rect.height * 0.30); + + await twoFingerTap(tester, at: clearOfControls, spread: 24); + expect(transportCommands, hasLength(1), reason: 'baseline: the spot is live with the strip shut'); + + chrome.setContentStripVisible(true); + await tester.pump(); + + await twoFingerTap(tester, at: clearOfControls, spread: 24); + + expect(transportCommands, hasLength(1), reason: 'chapter/queue browsing owns the surface'); + + chrome.setContentStripVisible(false); + await settle(tester); + }); + + testWidgets('a guest without playback control cannot toggle', (tester) async { + await pumpControls(tester, canControl: false); + + await twoFingerTap(tester); + + expect(transportCommands, isEmpty); + expect(player.state.playing, isTrue); + + await settle(tester); + }); +} + +int _pointerSequence = 0; + +int _nextPointer() => ++_pointerSequence; + +/// Minimal [Player] tracking playback state against a fixed 45-minute item. +class _RecordingPlayer implements Player { + bool _playing = true; + + @override + String get playerType => 'mpv'; + + @override + PlayerState get state => PlayerState( + playing: _playing, + position: const Duration(minutes: 10), + duration: const Duration(minutes: 45), + seekable: true, + ); + + @override + PlayerStreams get streams => PlayerStreams( + playing: const Stream.empty(), + completed: const Stream.empty(), + buffering: const Stream.empty(), + position: const Stream.empty(), + duration: const Stream.empty(), + seekable: const Stream.empty(), + buffer: const Stream.empty(), + volume: const Stream.empty(), + rate: const Stream.empty(), + tracks: const Stream.empty(), + track: const Stream.empty(), + log: const Stream.empty(), + error: const Stream.empty(), + audioDevice: const Stream.empty(), + audioDevices: const Stream>.empty(), + bufferRanges: const Stream>.empty(), + playbackRestart: const Stream.empty(), + backendSwitched: const Stream.empty(), + ); + + @override + Future play() async => _playing = true; + + @override + Future pause() async => _playing = false; + + @override + Future playOrPause() async => _playing = !_playing; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/widgets/video_controls_two_finger_tap_tracker_test.dart b/test/widgets/video_controls_two_finger_tap_tracker_test.dart new file mode 100644 index 00000000..196e4441 --- /dev/null +++ b/test/widgets/video_controls_two_finger_tap_tracker_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapTouchSlop; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/video_controls/helpers/two_finger_tap_tracker.dart'; + +/// A two-finger tap toggles playback with the chrome left down (#1505), and it +/// fires the moment the chord resolves — pausing late is pausing on the wrong +/// frame, so there is no pairing window to wait out. +/// +/// The tracker owns the whole accept/reject decision, so it must take a +/// deliberate two-finger tap and refuse everything a viewer does by accident: a +/// pinch, a drag, a palm, two fingers resting on the screen. +void main() { + late DateTime now; + late TwoFingerTapTracker tracker; + + void advance(Duration duration) { + now = now.add(duration); + } + + setUp(() { + now = DateTime(2026); + tracker = TwoFingerTapTracker(now: () => now); + }); + + test('detects a two-finger tap on the last lift', () { + tracker.pointerDown(1, const Offset(100, 100)); + tracker.pointerDown(2, const Offset(140, 100)); + + expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse, reason: 'one finger is still down'); + expect(tracker.pointerUp(2, const Offset(140, 100)), isTrue); + }); + + test('every two-finger tap reports independently, however fast they come', () { + for (var pointer = 1; pointer <= 7; pointer += 2) { + tracker.pointerDown(pointer, const Offset(100, 100)); + tracker.pointerDown(pointer + 1, const Offset(140, 100)); + expect(tracker.pointerUp(pointer, const Offset(100, 100)), isFalse); + expect( + tracker.pointerUp(pointer + 1, const Offset(140, 100)), + isTrue, + reason: 'tap $pointer must stand alone — no pair is ever swallowed', + ); + advance(const Duration(milliseconds: 20)); + } + }); + + test('does not detect a one-finger tap', () { + tracker.pointerDown(1, const Offset(100, 100)); + expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse); + + advance(const Duration(milliseconds: 120)); + + tracker.pointerDown(2, const Offset(100, 100)); + expect(tracker.pointerUp(2, const Offset(100, 100)), isFalse); + }); + + test('movement beyond the slop invalidates the tap', () { + tracker.pointerDown(1, const Offset(100, 100)); + tracker.pointerDown(2, const Offset(140, 100)); + tracker.pointerMove(1, const Offset(140, 140)); + + expect(tracker.pointerUp(1, const Offset(140, 140)), isFalse); + expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); + }); + + test('movement within the slop still taps', () { + const drift = Offset(100 + kDoubleTapTouchSlop / 2, 100); + tracker.pointerDown(1, const Offset(100, 100)); + tracker.pointerDown(2, const Offset(140, 100)); + tracker.pointerMove(1, drift); + + expect(tracker.pointerUp(1, drift), isFalse); + expect(tracker.pointerUp(2, const Offset(140, 100)), isTrue); + }); + + test('a pinch is not a tap even when both fingers land back near their start', () { + tracker.pointerDown(1, const Offset(100, 100)); + tracker.pointerDown(2, const Offset(140, 100)); + tracker.pointerMove(1, const Offset(40, 100)); + tracker.pointerMove(2, const Offset(200, 100)); + tracker.pointerMove(1, const Offset(100, 100)); + tracker.pointerMove(2, const Offset(140, 100)); + + expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse); + expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); + }); + + test('a third finger invalidates the tap', () { + tracker.pointerDown(1, const Offset(100, 100)); + tracker.pointerDown(2, const Offset(140, 100)); + tracker.pointerDown(3, const Offset(180, 100)); + + expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse); + expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); + expect(tracker.pointerUp(3, const Offset(180, 100)), isFalse); + }); + + test('two fingers resting past the tap timeout do not tap', () { + tracker.pointerDown(1, const Offset(100, 100)); + tracker.pointerDown(2, const Offset(140, 100)); + + advance(kDoubleTapTimeout + const Duration(milliseconds: 1)); + + expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse); + expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); + }); + + test('the timeout runs from the earliest finger down, not the latest', () { + tracker.pointerDown(1, const Offset(100, 100)); + advance(kDoubleTapTimeout - const Duration(milliseconds: 20)); + tracker.pointerDown(2, const Offset(140, 100)); + advance(const Duration(milliseconds: 40)); + + expect(tracker.pointerUp(1, const Offset(100, 100)), isFalse); + expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); + }); + + test('a cancelled pointer invalidates the tap', () { + tracker.pointerDown(1, const Offset(100, 100)); + tracker.pointerDown(2, const Offset(140, 100)); + tracker.pointerCancel(1); + + expect(tracker.pointerUp(2, const Offset(140, 100)), isFalse); + }); + + test('reports the chord while two fingers are down', () { + expect(tracker.isChordActive, isFalse); + + tracker.pointerDown(1, const Offset(100, 100)); + expect(tracker.isChordActive, isFalse, reason: 'one finger is not a chord'); + + tracker.pointerDown(2, const Offset(140, 100)); + expect(tracker.isChordActive, isTrue); + + tracker.pointerUp(1, const Offset(100, 100)); + expect(tracker.isChordActive, isTrue, reason: 'the candidate outlives the first lift'); + + tracker.pointerUp(2, const Offset(140, 100)); + expect(tracker.isChordActive, isFalse); + }); +}