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); + }); +}