fix(player): keep double-tap seek working over the play overlay (DR-098)
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m5s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped

The control-surface guard added in the previous commit killed
double-tap-to-seek. The first tap pauses, which renders the full-screen
<button> play overlay over the video, so the SECOND tap lands on a
button — and the guard discarded it as "a tap on a control".

Mark that overlay `data-player-surface`: visually it IS the video, so it
must keep taking tap gestures despite being a <button>. The marker wins
over the interactive-tag check in isControlSurfaceTouch.

Adds VideoPlayer.tapSurface.test.ts, which renders the REAL component
and dispatches real touch/click events at whatever element is genuinely
on top. This is the gap that let four bugs ship in a row: the pure-unit
tests over registerTap/isControlSurfaceTouch/isSynthesizedTouchClick all
passed throughout, because each helper behaved exactly as specified —
every bug was in the composition, i.e. which element actually receives a
tap after Svelte re-renders. Modelling that DOM by hand in a test would
just re-encode the same wrong assumption, so these render it instead.

The new double-tap test was verified to fail with the fix reverted and
pass with it applied, in both directions.
This commit is contained in:
2026-07-30 15:44:38 +02:00
parent dc8b732465
commit b12e99b7e1
7 changed files with 240 additions and 12 deletions
+14 -4
View File
@@ -1448,7 +1448,11 @@
* `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
*/
function ancestorChain(target: EventTarget | null) {
const chain: Array<{ tag: string; isPlayerControls?: boolean }> = [];
const chain: Array<{
tag: string;
isPlayerControls?: boolean;
isPlayerSurface?: boolean;
}> = [];
let node = target as HTMLElement | null;
// Bounded walk: controls live a few levels below the player root, and
// stopping at <body> keeps this cheap and avoids depending on a bound ref.
@@ -1456,6 +1460,7 @@
chain.push({
tag: node.tagName ?? "",
isPlayerControls: node.dataset?.playerControls !== undefined,
isPlayerSurface: node.dataset?.playerSurface !== undefined,
});
node = node.parentElement;
}
@@ -1807,10 +1812,15 @@
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if !isPlaying}
<!-- 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). -->
<!-- Play overlay. Visually this IS the video surface, so it is marked
`data-player-surface`: it must keep participating in tap gestures even
though it is a <button>, or the second tap of a double tap (which lands
here, because the first tap paused and raised this overlay) is
discarded as "a tap on a control" and seeking dies. It still shares the
synthesized-click guard, since it appears exactly when a tap pauses.
See DR-098. -->
<button
data-player-surface
class="absolute inset-0 flex items-center justify-center bg-black/30"
onclick={handleSurfaceClick}
aria-label="Play"