fix(player): controls bar taps are not player gestures (DR-098)

The bottom play/pause button did nothing. The gesture listener lives on
the outer container and touch events bubble, so tapping the button ran
handleTouchStart (toggle #1) and then the button's own onclick (toggle
#2). The two cancelled out, leaving the control apparently dead.

Ignore container-level gestures for touches that land on an interactive
control: buttons, links, inputs (the seek bar), or anything inside the
controls bar, now marked `data-player-controls`. The rule itself is a
pure function over the ancestor chain (isControlSurfaceTouch), so it is
unit tested without a DOM.

Same root shape as the play-overlay bug in the previous commit: a second
click target over the video that the gesture layer did not account for.
This commit is contained in:
2026-07-30 15:27:01 +02:00
parent b98a530f48
commit dc8b732465
7 changed files with 84 additions and 5 deletions
+29 -1
View File
@@ -26,6 +26,7 @@
resolveSeekTarget,
clampSeekTarget,
isSynthesizedTouchClick,
isControlSurfaceTouch,
SEEK_FORWARD_SECONDS,
SEEK_BACKWARD_SECONDS,
type TapFeedback,
@@ -1442,8 +1443,33 @@
}
}
/**
* Walk up from the touch target collecting the tag/attribute pairs
* `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
*/
function ancestorChain(target: EventTarget | null) {
const chain: Array<{ tag: string; isPlayerControls?: 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.
while (node && node.tagName !== "BODY") {
chain.push({
tag: node.tagName ?? "",
isPlayerControls: node.dataset?.playerControls !== undefined,
});
node = node.parentElement;
}
return chain;
}
// Touch gesture handlers
function handleTouchStart(e: TouchEvent) {
// Taps on the controls belong to those controls. This listener is on the
// container and touch events bubble, so without this a tap on the bottom
// play button would toggle here AND again via the button's own click — the
// two cancelling out and leaving the control apparently dead (DR-098).
if (isControlSurfaceTouch(ancestorChain(e.target))) return;
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
@@ -1834,8 +1860,10 @@
{/if}
</div>
<!-- Controls -->
<!-- Controls. `data-player-controls` marks this subtree as interactive so
container-level tap gestures ignore touches here (see DR-098). -->
<div
data-player-controls
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}