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
This commit is contained in:
+20
-53
@@ -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<int, _TrackedTouch> _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;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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<PlexVideoControls>
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user