fix(player): guard the play overlay against the synthesized touch click

After the DR-098 tap rewrite, pausing became impossible while unpausing
always worked — an asymmetry that pointed straight at the overlay.

Pausing renders a full-screen play-overlay button over the video. The
compatibility click Android synthesizes from the tap arrives ~30-130ms
later, by which time that button exists, so the click lands on the
OVERLAY rather than the <video>. Its onclick called togglePlayPause with
no guard at all, resuming immediately. Unpausing was unaffected because
it removes the overlay, leaving nothing to intercept the click.

The suppression rule was only wired into the video element's handler.
Extract it as isSynthesizedTouchClick() in tapGestures.ts (unit-tested)
and use it from every click target layered over the video, the overlay
included.

Verified: 724 frontend tests pass, svelte-check clean. Bumped to 0.2.5
so the APK installs over 2004.
This commit is contained in:
2026-07-30 15:10:59 +02:00
parent b565c4ae6f
commit b98a530f48
8 changed files with 73 additions and 18 deletions
+12 -13
View File
@@ -25,6 +25,7 @@
registerTap,
resolveSeekTarget,
clampSeekTarget,
isSynthesizedTouchClick,
SEEK_FORWARD_SECONDS,
SEEK_BACKWARD_SECONDS,
type TapFeedback,
@@ -115,7 +116,6 @@
// When a touch tap last ran the gesture handler, so the compatibility click
// the browser synthesizes afterwards can be ignored (see handleVideoClick).
let lastTouchTapAt = 0;
const TOUCH_CLICK_SUPPRESS_MS = 700;
let brightness = $state(1); // 0-2, default 1
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -1513,17 +1513,14 @@
/**
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
* `handleTouchStart`, so the compatibility click the browser synthesizes after
* a tap must be ignored here or every tap toggles twice.
* a tap must be ignored or every tap toggles twice.
*
* Two independent guards, because neither alone is sufficient: `detail === 0`
* catches the synthesized click on engines that report it, and the recency
* check covers engines that report a real `detail` — Android's WebView can
* deliver the click well after the touch, which is what defeated the previous
* timer-based guard (see DR-098).
* Used by EVERY click target layered over the video, not just the <video>:
* pausing renders the full-screen play overlay, so the synthesized click lands
* on that button instead and would re-toggle straight back to playing.
*/
function handleVideoClick(e: MouseEvent) {
if (e.detail === 0) return;
if (Date.now() - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS) return;
function handleSurfaceClick(e: MouseEvent) {
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
togglePlayPause();
}
@@ -1691,7 +1688,7 @@
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={handleVideoClick}
onclick={handleSurfaceClick}
>
<!-- Temporarily disabled to debug playback issues
{#each subtitleTracks() as track}
@@ -1784,10 +1781,12 @@
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if !isPlaying}
<!-- Play/Pause overlay -->
<!-- Play overlay. Must share the touch-click guard: this button appears the
instant a tap pauses, so the synthesized click lands here and would
resume immediately (see DR-098). -->
<button
class="absolute inset-0 flex items-center justify-center bg-black/30"
onclick={togglePlayPause}
onclick={handleSurfaceClick}
aria-label="Play"
>
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">