feat(player): defer single tap so a double tap doesn't also toggle pause
A tap cannot be classified when it lands — it may still turn out to be the first half of a double tap. Play/pause is therefore deferred until the 300ms double-tap window closes, and cancelled outright if a second tap arrives, so a double tap seeks without also toggling pause. Forward skip moves from 10s to 30s (back stays 10s), for both double tap and the keyboard arrows. The timing rules live in tapGestures.ts so they are unit-testable without mounting the player. Rapid double taps now chain off a still-in-flight seek target instead of all resolving against the same not-yet-updated position. TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
|
||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092 -->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -20,6 +20,14 @@
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
resolveSeekTarget,
|
||||
SEEK_FORWARD_SECONDS,
|
||||
SEEK_BACKWARD_SECONDS,
|
||||
type TapFeedback,
|
||||
} from "./tapGestures";
|
||||
import {
|
||||
setBackgroundAudioEnabled,
|
||||
subscribeAppBackgrounded,
|
||||
@@ -102,11 +110,14 @@
|
||||
let touchStartX = $state(0);
|
||||
let touchStartY = $state(0);
|
||||
let touchStartTime = $state(0);
|
||||
let lastTapTime = $state(0);
|
||||
let tapGestures = createTapGestureState();
|
||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let brightness = $state(1); // 0-2, default 1
|
||||
let showDoubleTapFeedback = $state<"left" | "right" | null>(null);
|
||||
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
// Target of a skip already requested but not yet reported back by the player,
|
||||
// so back-to-back double taps chain instead of stacking on a stale position.
|
||||
let pendingSeekTarget: number | null = null;
|
||||
let swipeGestureActive = $state(false);
|
||||
|
||||
// Backend info from Rust (Rust decides which backend to use based on platform)
|
||||
@@ -703,6 +714,16 @@
|
||||
if (debugLogInterval) {
|
||||
clearInterval(debugLogInterval);
|
||||
}
|
||||
// A deferred single tap must not fire play/pause after teardown.
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
tapGestures.cancel();
|
||||
if (doubleTapFeedbackTimeout) {
|
||||
clearTimeout(doubleTapFeedbackTimeout);
|
||||
doubleTapFeedbackTimeout = null;
|
||||
}
|
||||
|
||||
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
||||
for (const unlisten of nativeUnlisteners) {
|
||||
@@ -1192,9 +1213,13 @@
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
backgroundAudioOn = !backgroundAudioOn;
|
||||
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
|
||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||
// so exactly one background behavior is active.
|
||||
setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
if (!armed) {
|
||||
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
|
||||
}
|
||||
setAutoEnterEnabled(!backgroundAudioOn);
|
||||
}
|
||||
|
||||
@@ -1342,7 +1367,16 @@
|
||||
async function seekRelative(seconds: number) {
|
||||
isSeeking = true;
|
||||
|
||||
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||
// The facade seeks by absolute position, so resolve the delta here —
|
||||
// chaining off a still-in-flight target so rapid double taps accumulate
|
||||
// instead of all resolving against the same not-yet-updated position.
|
||||
const newTime = resolveSeekTarget({
|
||||
delta: seconds,
|
||||
reportedPosition: currentTime,
|
||||
duration,
|
||||
pendingTarget: pendingSeekTarget,
|
||||
});
|
||||
pendingSeekTarget = newTime;
|
||||
|
||||
console.log("[VideoPlayer] Relative seek:", {
|
||||
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
||||
@@ -1358,7 +1392,12 @@
|
||||
}
|
||||
} as unknown as Event;
|
||||
|
||||
await handleSeekBarChange(syntheticEvent);
|
||||
try {
|
||||
await handleSeekBarChange(syntheticEvent);
|
||||
} finally {
|
||||
// The player is authoritative again from here on.
|
||||
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -1375,10 +1414,10 @@
|
||||
}
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
seekRelative(-10);
|
||||
seekRelative(SEEK_BACKWARD_SECONDS);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
seekRelative(10);
|
||||
seekRelative(SEEK_FORWARD_SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,25 +1428,31 @@
|
||||
touchStartY = touch.clientY;
|
||||
touchStartTime = Date.now();
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastTap = now - lastTapTime;
|
||||
const outcome = registerTap(tapGestures, {
|
||||
x: touch.clientX,
|
||||
screenWidth: window.innerWidth,
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
// Double tap detection (within 300ms)
|
||||
if (timeSinceLastTap < 300 && timeSinceLastTap > 0) {
|
||||
e.preventDefault();
|
||||
handleDoubleTap(touch.clientX);
|
||||
lastTapTime = 0; // Reset to prevent triple-tap
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
} else {
|
||||
lastTapTime = now;
|
||||
// Set timeout to clear if no second tap
|
||||
tapTimeout = setTimeout(() => {
|
||||
lastTapTime = 0;
|
||||
}, 300);
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
|
||||
if (outcome.action === "seek") {
|
||||
e.preventDefault();
|
||||
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single tap so far: defer play/pause until the double-tap window closes,
|
||||
// so a double tap seeks without also toggling pause.
|
||||
tapTimeout = setTimeout(() => {
|
||||
tapTimeout = null;
|
||||
if (tapGestures.resolvePending(Date.now())) {
|
||||
togglePlayPause();
|
||||
}
|
||||
}, outcome.pendingAfterMs);
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
@@ -1422,6 +1467,13 @@
|
||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||
swipeGestureActive = true;
|
||||
|
||||
// This is a swipe, not a tap — drop the deferred play/pause.
|
||||
tapGestures.cancel();
|
||||
if (tapTimeout) {
|
||||
clearTimeout(tapTimeout);
|
||||
tapTimeout = null;
|
||||
}
|
||||
|
||||
// Brightness control on vertical swipe
|
||||
swipeType = "brightness";
|
||||
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
|
||||
@@ -1438,19 +1490,21 @@
|
||||
swipeType = null;
|
||||
}
|
||||
|
||||
function handleDoubleTap(x: number) {
|
||||
const screenWidth = window.innerWidth;
|
||||
const isLeftSide = x < screenWidth / 2;
|
||||
/**
|
||||
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
||||
* by `handleTouchStart` (which defers play/pause past the double-tap window),
|
||||
* so the compatibility click that follows a tap must be ignored here —
|
||||
* otherwise it pauses on the first tap of a double tap.
|
||||
*/
|
||||
function handleVideoClick(e: MouseEvent) {
|
||||
// A click synthesized from a touch reports no pointer movement detail.
|
||||
if (e.detail === 0 || tapTimeout !== null) return;
|
||||
togglePlayPause();
|
||||
}
|
||||
|
||||
if (isLeftSide) {
|
||||
// Double tap left: rewind 10 seconds
|
||||
seekRelative(-10);
|
||||
showDoubleTapFeedback = "left";
|
||||
} else {
|
||||
// Double tap right: forward 10 seconds
|
||||
seekRelative(10);
|
||||
showDoubleTapFeedback = "right";
|
||||
}
|
||||
function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
|
||||
seekRelative(seekSeconds);
|
||||
showDoubleTapFeedback = feedback;
|
||||
|
||||
// Hide feedback after animation
|
||||
if (doubleTapFeedbackTimeout) {
|
||||
@@ -1612,7 +1666,7 @@
|
||||
onwaiting={handleWaiting}
|
||||
onplaying={handlePlaying}
|
||||
onloadstart={handleLoadStart}
|
||||
onclick={togglePlayPause}
|
||||
onclick={handleVideoClick}
|
||||
>
|
||||
<!-- Temporarily disabled to debug playback issues
|
||||
{#each subtitleTracks() as track}
|
||||
@@ -1665,7 +1719,7 @@
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">-10</text>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1676,7 +1730,7 @@
|
||||
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
||||
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+10</text>
|
||||
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user