diff --git a/package.json b/package.json index 21cbc66e..1d2f0c6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jellytau", - "version": "0.2.5", + "version": "0.2.6", "description": "", "type": "module", "packageManager": "bun@1.3.5", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0588c06c..e1c68c6b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "jellytau" -version = "0.2.5" +version = "0.2.6" dependencies = [ "aes-gcm", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f47a2556..4f588f8c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jellytau" -version = "0.2.5" +version = "0.2.6" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dd7d84ef..eb2bfc48 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "jellytau", - "version": "0.2.5", + "version": "0.2.6", "identifier": "com.dtourolle.jellytau", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index 93c2386a..7e95be49 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -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 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} - +
{ }); }); +describe("control-surface touches are not gestures", () => { + // Regression: the gesture listener is on the outer container and touch events + // bubble, so tapping the bottom play/pause button ran the gesture handler + // (toggle #1) AND the button's own click handler (toggle #2). The two + // cancelled out and the control appeared dead. + it("treats a tap on a button as a control, not a gesture", () => { + expect(isControlSurfaceTouch([{ tag: "svg" }, { tag: "button" }, { tag: "div" }])).toBe(true); + }); + + it("treats the seek bar input as a control", () => { + expect(isControlSurfaceTouch([{ tag: "input" }, { tag: "div" }])).toBe(true); + }); + + it("treats anything inside the controls bar as a control", () => { + expect( + isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }]) + ).toBe(true); + }); + + it("lets a tap on the bare video surface through as a gesture", () => { + expect(isControlSurfaceTouch([{ tag: "video" }, { tag: "div" }, { tag: "div" }])).toBe(false); + }); + + it("is case-insensitive about tag names", () => { + expect(isControlSurfaceTouch([{ tag: "BUTTON" }])).toBe(true); + }); +}); + describe("synthesized touch-click suppression", () => { // Regression: pausing renders a full-screen play-overlay button over the // video, so the compatibility click Android synthesizes from the tap lands on diff --git a/src/lib/components/player/tapGestures.ts b/src/lib/components/player/tapGestures.ts index 073ac52a..f0007314 100644 --- a/src/lib/components/player/tapGestures.ts +++ b/src/lib/components/player/tapGestures.ts @@ -31,6 +31,28 @@ export const DOUBLE_TAP_WINDOW_MS = 300; */ export const TOUCH_CLICK_SUPPRESS_MS = 700; +/** + * Whether a touch landed on an interactive control rather than the bare video + * surface, and so must NOT be interpreted as a play/pause or seek gesture. + * + * The gesture listener sits on the outer container, and touch events bubble, so + * without this a tap on the bottom control bar runs the gesture handler (toggle + * #1) *and* the button's own click handler (toggle #2) — the two cancel out and + * the button appears dead. Buttons, links, inputs (the seek bar), and anything + * inside an element marked `data-player-controls` are treated as controls. + * + * Takes the ancestor chain as plain tag/attribute pairs so the rule is unit + * testable without a DOM. + */ +export function isControlSurfaceTouch( + ancestors: Array<{ tag: string; isPlayerControls?: boolean }> +): boolean { + const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]); + return ancestors.some( + (node) => node.isPlayerControls === true || INTERACTIVE.has(node.tag.toLowerCase()) + ); +} + /** * Whether a `click` should be ignored because a touch tap already handled it. *