fix(player): require a fresh double tap for every mobile skip-zone seek

The skip badge doubled as an armed state: while it was up, any single tap
in the same-direction zone seeked again. The badge is also raised by
keyboard, D-pad, media-transport and live seeks, so one remote press armed
one-tap seeking on the touch surface with no double tap at all. It stayed
armed for 1200 ms plus the fade and renewed on every tap, leaving the side
zones - 35% of the width each, over 70% of the height - unable to raise
the chrome.

Pair taps off the pending single-tap timer rather than differencing
DateTime.now(). The window is then one deadline that a clock adjustment
cannot stretch, suppressing touch taps disarms a half-finished pair, and
_lastSkipTapTime belongs to the desktop double-click paths alone.

Consecutive completed skips still accumulate into one running badge total.
This commit is contained in:
edde746
2026-07-29 05:05:40 +02:00
parent f3795d49eb
commit f02924b9a5
3 changed files with 424 additions and 31 deletions
@@ -559,45 +559,41 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
_lastSkipTapTime = now;
}
/// Handle tap in skip zone with custom double-tap detection
/// Handle a tap in a skip zone. Every skip costs a fresh same-direction
/// double tap; a lone tap toggles the chrome.
///
/// The badge left over from the previous skip is a readout, not an armed
/// state — it says nothing about what the next tap does.
///
/// The pending single-tap timer *is* the pairing window: while it is live the
/// tap that started it is still unresolved, so a same-direction tap pairs with
/// it. One deadline instead of a timer plus a `DateTime.now()` difference that
/// a clock adjustment could stretch or collapse — and it leaves
/// [_lastSkipTapTime] to the desktop double-click paths alone. Suppressing
/// touch taps cancels the timer, which correctly disarms a half-finished pair.
void _handleTapInSkipZone({required bool isForward}) {
if (_isTouchTapSuppressed) return;
// Cancel any pending single-tap action
final pairsWithPendingTap = (_singleTapTimer?.isActive ?? false) && _lastSkipTapWasForward == isForward;
// Either way the pending tap is resolved now: paired below, or replaced by
// this one as the start of a new pair.
_singleTapTimer?.cancel();
_singleTapTimer = null;
// While the skip readout is visible, every tap in the same-direction zone
// stacks another skip immediately — repeat skips cost one tap, not a
// fresh double-tap. A tap in the opposite zone falls through to pairing.
if (_showDoubleTapFeedback && _lastDoubleTapWasForward == isForward) {
if (pairsWithPendingTap) {
_handleDoubleTapSkip(isForward: isForward);
return;
}
final now = DateTime.now();
final isDoubleTap =
_lastSkipTapTime != null &&
now.difference(_lastSkipTapTime!) < kDoubleTapTimeout &&
_lastSkipTapWasForward == isForward;
_lastSkipTapWasForward = isForward;
// Skip ONLY on detected double-tap (no single-tap-to-add behavior)
if (isDoubleTap) {
_lastSkipTapTime = null;
_handleDoubleTapSkip(isForward: isForward);
} else {
// First tap - record timestamp and start timer for single-tap action
_lastSkipTapTime = now;
_lastSkipTapWasForward = isForward;
// If no second tap within the double-tap window, treat as single tap
// to toggle controls
_singleTapTimer = Timer(kDoubleTapTimeout, () {
if (mounted) {
_toggleControls();
}
});
}
// No partner within the window, and this resolves as a lone tap.
_singleTapTimer = Timer(kDoubleTapTimeout, () {
if (mounted) {
_toggleControls();
}
});
}
Size _sizeOf(BuildContext context) {
@@ -613,7 +609,7 @@ extension _PlexVideoControlsPlaybackInputMethods on _PlexVideoControlsState {
_showSkipFeedback(isForward: isForward);
}
/// Handle a skip-zone double tap (and every stacked tap that follows it).
/// Handle a completed skip-zone double tap.
void _handleDoubleTapSkip({required bool isForward}) {
if (!widget.canControl) return;
@@ -659,11 +659,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
bool _lastDoubleTapWasForward = true;
Timer? _feedbackTimer;
int _accumulatedSkipSeconds = 0; // Stacking skip: total skip during active feedback
// Custom tap detection state (more reliable than Flutter's onDoubleTap)
// Desktop double-click detection (more reliable than Flutter's onDoubleTap).
// The mobile skip zones do not use this; they pair off _singleTapTimer.
DateTime? _lastSkipTapTime;
// Direction of the skip-zone tap _singleTapTimer is currently counting down.
bool _lastSkipTapWasForward = true;
Timer? _feedbackHideTimer; // Removes the skip readout after its fade-out completes
Timer? _singleTapTimer; // Timer for delayed single-tap action (toggle controls)
// 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 MobileEdgeAdjustmentTracker _edgeAdjustmentTracker = MobileEdgeAdjustmentTracker();
final DeviceAdjustmentService _deviceAdjustmentService = DeviceAdjustmentService.instance;