fix(player): tap gestures act immediately, no deferral timer (DR-098)

Tapping the video surface pause-looped: it would unpause and bounce
straight back to paused about a second later. Long-press unpaused fine,
which is what pinned it to the tap path rather than the media pipeline.

The gesture handler deferred the first tap's play/pause behind a 300ms
timer so a second tap could cancel it and seek instead. But the timer
callback cleared its own handle *before* invoking the toggle, and
handleVideoClick used exactly that handle (`tapTimeout !== null`) to
suppress the compatibility click Android's WebView synthesizes after a
touch. So the guard was already open when the late click arrived, and it
toggled a second time.

Replace the deferral with immediate action — there are only first and
second taps:

  1st tap: toggle play/pause
  2nd tap: seek, then toggle play/pause again

The second toggle undoes the first, so a double tap seeks while leaving
the play state exactly as it was: playing jumps and keeps playing,
paused jumps and stays paused. No timer, no window race, no loop.

Click suppression no longer depends on the timer: ignore detail === 0
and any click within 700ms of a touch tap, since Android can deliver the
synthesized click late and with a real detail value.

A swipe now undoes the touchstart toggle (latched on swipeGestureActive
so it happens once, not per touchmove frame), keeping brightness swipes
from changing the play state.

UT-085..087 described the old deferred behaviour and are updated to the
new contract. UT-091 is used for the DR-097 facade tests, since UT-089
and UT-090 were already claimed by extract-traces.test.ts.
This commit is contained in:
2026-07-30 14:53:20 +02:00
parent 79e10d7485
commit b565c4ae6f
11 changed files with 144 additions and 104 deletions
+6 -4
View File
@@ -248,6 +248,7 @@ Internal architecture, components, and application logic.
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done | | DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / 10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done | | DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / 10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done | | DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state | UI | UR-061 | Done |
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done | | DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done | | DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done | | DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
@@ -411,10 +412,11 @@ Internal architecture, components, and application logic.
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done | | UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done | | UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done | | UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
| UT-085 | A first tap resolves to `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | Done | | UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | DR-092, DR-098 | Done |
| UT-086 | A second tap inside the window seeks (+30 s right half, 10 s left half) with the matching feedback side, and clears the deferred play/pause so a double tap never pauses | DR-092 | Done | | UT-086 | A second tap inside the window seeks (+30 s right half, 10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
| UT-087 | A tap after the window, and a third tap after a consumed double tap, each start a fresh pending tap; repeated double taps keep seeking; `cancel()` drops a pending tap so a swipe cannot pause | DR-092 | Done | | UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps to `[0, duration]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092 | Done | | UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
### Integration Tests ### Integration Tests
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jellytau", "name": "jellytau",
"version": "0.2.3", "version": "0.2.4",
"description": "", "description": "",
"type": "module", "type": "module",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
+2 -2
View File
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
expect(defined.UR).toBe(61); expect(defined.UR).toBe(61);
expect(defined.IR).toBe(29); expect(defined.IR).toBe(29);
expect(defined.DR).toBe(94); expect(defined.DR).toBe(95);
expect(defined.JA).toBe(32); expect(defined.JA).toBe(32);
expect(defined.total).toBe(216); expect(defined.total).toBe(217);
}); });
}); });
+1 -1
View File
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]] [[package]]
name = "jellytau" name = "jellytau"
version = "0.2.3" version = "0.2.4"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jellytau" name = "jellytau"
version = "0.2.3" version = "0.2.4"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau", "productName": "jellytau",
"version": "0.2.3", "version": "0.2.4",
"identifier": "com.dtourolle.jellytau", "identifier": "com.dtourolle.jellytau",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+33 -31
View File
@@ -112,7 +112,10 @@
let touchStartY = $state(0); let touchStartY = $state(0);
let touchStartTime = $state(0); let touchStartTime = $state(0);
let tapGestures = createTapGestureState(); let tapGestures = createTapGestureState();
let tapTimeout: ReturnType<typeof setTimeout> | null = null; // 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 brightness = $state(1); // 0-2, default 1
let showDoubleTapFeedback = $state<TapFeedback | null>(null); let showDoubleTapFeedback = $state<TapFeedback | null>(null);
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null; let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -719,11 +722,6 @@
if (debugLogInterval) { if (debugLogInterval) {
clearInterval(debugLogInterval); clearInterval(debugLogInterval);
} }
// A deferred single tap must not fire play/pause after teardown.
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
tapGestures.cancel(); tapGestures.cancel();
if (doubleTapFeedbackTimeout) { if (doubleTapFeedbackTimeout) {
clearTimeout(doubleTapFeedbackTimeout); clearTimeout(doubleTapFeedbackTimeout);
@@ -1457,25 +1455,22 @@
now: Date.now(), now: Date.now(),
}); });
if (tapTimeout) { // Suppress the compatibility click this touch will synthesize.
clearTimeout(tapTimeout); lastTouchTapAt = Date.now();
tapTimeout = null;
}
if (outcome.action === "seek") { if (outcome.action === "seek") {
e.preventDefault(); e.preventDefault();
handleDoubleTap(outcome.seekSeconds, outcome.feedback); handleDoubleTap(outcome.seekSeconds, outcome.feedback);
// Re-toggle so the first tap's toggle is undone: a double tap seeks and
// leaves the play state as it was (playing keeps playing, paused stays
// paused).
if (outcome.togglePlayPause) togglePlayPause();
return; return;
} }
// Single tap so far: defer play/pause until the double-tap window closes, // First tap: act now. Nothing is deferred, so there is no timer to race the
// so a double tap seeks without also toggling pause. // compatibility click Android synthesizes after a touch tap (see DR-098).
tapTimeout = setTimeout(() => { togglePlayPause();
tapTimeout = null;
if (tapGestures.resolvePending(Date.now())) {
togglePlayPause();
}
}, outcome.pendingAfterMs);
} }
function handleTouchMove(e: TouchEvent) { function handleTouchMove(e: TouchEvent) {
@@ -1488,14 +1483,16 @@
// Minimum movement to register as swipe (50px) // Minimum movement to register as swipe (50px)
if (Math.abs(deltaY) > 50 && timeDelta > 50) { if (Math.abs(deltaY) > 50 && timeDelta > 50) {
swipeGestureActive = true; // Only on the frame the gesture is first recognised as a swipe this runs
// on every touchmove, and the correction below must happen exactly once.
// This is a swipe, not a tap — drop the deferred play/pause. if (!swipeGestureActive) {
tapGestures.cancel(); // The touchstart already toggled play/pause (taps act immediately now),
if (tapTimeout) { // so undo it: a swipe must not change the play state. Forget the tap too,
clearTimeout(tapTimeout); // so it cannot pair with a later tap into a spurious seek.
tapTimeout = null; togglePlayPause();
tapGestures.cancel();
} }
swipeGestureActive = true;
// Brightness control on vertical swipe // Brightness control on vertical swipe
swipeType = "brightness"; swipeType = "brightness";
@@ -1514,14 +1511,19 @@
} }
/** /**
* Mouse clicks toggle play/pause immediately. Touch taps are already handled * Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
* by `handleTouchStart` (which defers play/pause past the double-tap window), * `handleTouchStart`, so the compatibility click the browser synthesizes after
* so the compatibility click that follows a tap must be ignored here — * a tap must be ignored here or every tap toggles twice.
* otherwise it pauses on the first tap of a double tap. *
* 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).
*/ */
function handleVideoClick(e: MouseEvent) { function handleVideoClick(e: MouseEvent) {
// A click synthesized from a touch reports no pointer movement detail. if (e.detail === 0) return;
if (e.detail === 0 || tapTimeout !== null) return; if (Date.now() - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS) return;
togglePlayPause(); togglePlayPause();
} }
+45 -27
View File
@@ -27,24 +27,22 @@ function asSeek(outcome: ReturnType<typeof tap>) {
} }
describe("tap gesture resolution", () => { describe("tap gesture resolution", () => {
it("defers the single-tap action until the double-tap window has elapsed", () => { // Every tap acts IMMEDIATELY — there is no deferral and no timer.
const state = createTapGestureState(); //
const first = tap(state, RIGHT, 1000); // 1st tap: toggle play/pause
// 2nd tap: seek, then toggle play/pause AGAIN
//
// The second toggle undoes the first, so a double tap seeks while leaving the
// play state exactly as it was: playing -> jump and keep playing; paused ->
// jump and stay paused. The old design deferred the first tap behind a 300ms
// timer, which raced the synthesized click and produced a pause/unpause loop.
// The first tap must NOT immediately toggle play/pause — it may still it("toggles play/pause immediately on the first tap", () => {
// become a double tap. const state = createTapGestureState();
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS }); expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
}); });
it("resolves an isolated tap to togglePlayPause once the window expires", () => { it("seeks forward 30s AND toggles again on a second right-side tap", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
expect(resolved).toEqual({ action: "togglePlayPause" });
});
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
const state = createTapGestureState(); const state = createTapGestureState();
tap(state, RIGHT, 1000); tap(state, RIGHT, 1000);
const second = asSeek(tap(state, RIGHT, 1150)); const second = asSeek(tap(state, RIGHT, 1150));
@@ -52,12 +50,11 @@ describe("tap gesture resolution", () => {
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS); expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
expect(second.seekSeconds).toBe(30); expect(second.seekSeconds).toBe(30);
expect(second.feedback).toBe("right"); expect(second.feedback).toBe("right");
// The re-toggle is what preserves the play state across a double tap.
// The deferred single-tap pause must have been cancelled. expect(second.togglePlayPause).toBe(true);
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
}); });
it("seeks back 10s on a double tap on the left half", () => { it("seeks back 10s AND toggles again on a second left-side tap", () => {
const state = createTapGestureState(); const state = createTapGestureState();
tap(state, LEFT, 1000); tap(state, LEFT, 1000);
const second = asSeek(tap(state, LEFT, 1100)); const second = asSeek(tap(state, LEFT, 1100));
@@ -65,24 +62,44 @@ describe("tap gesture resolution", () => {
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS); expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
expect(second.seekSeconds).toBe(-10); expect(second.seekSeconds).toBe(-10);
expect(second.feedback).toBe("left"); expect(second.feedback).toBe("left");
expect(second.togglePlayPause).toBe(true);
}); });
it("treats a second tap after the window as a new pending single tap", () => { it("net play state is unchanged by a double tap (two toggles cancel out)", () => {
const state = createTapGestureState();
let playing = true;
const apply = (outcome: ReturnType<typeof tap>) => {
if (outcome.action === "togglePlayPause") playing = !playing;
else if (outcome.action === "seek" && outcome.togglePlayPause) playing = !playing;
};
apply(tap(state, RIGHT, 1000)); // toggle -> paused
apply(tap(state, RIGHT, 1100)); // seek + toggle -> playing again
expect(playing).toBe(true);
// And from paused, a double tap leaves it paused.
playing = false;
apply(tap(state, RIGHT, 2000));
apply(tap(state, RIGHT, 2100));
expect(playing).toBe(false);
});
it("treats a tap after the window as a fresh first tap", () => {
const state = createTapGestureState(); const state = createTapGestureState();
tap(state, RIGHT, 1000); tap(state, RIGHT, 1000);
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1); const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
expect(late.action).toBe("pending"); expect(late.action).toBe("togglePlayPause");
}); });
it("does not treat a third tap as another double tap", () => { it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
const state = createTapGestureState(); const state = createTapGestureState();
tap(state, RIGHT, 1000); tap(state, RIGHT, 1000);
expect(tap(state, RIGHT, 1100).action).toBe("seek"); expect(tap(state, RIGHT, 1100).action).toBe("seek");
// Triple tap: the third tap starts a fresh pending tap rather than // The pair is consumed. The next tap is a FIRST tap again, so it toggles
// seeking again off the consumed second tap. // play/pause — there is no "third tap" concept.
expect(tap(state, RIGHT, 1200).action).toBe("pending"); expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
}); });
it("accumulates repeated double taps on the same side", () => { it("accumulates repeated double taps on the same side", () => {
@@ -105,12 +122,13 @@ describe("tap gesture resolution", () => {
expect(second.feedback).toBe("right"); expect(second.feedback).toBe("right");
}); });
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => { it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
const state = createTapGestureState(); const state = createTapGestureState();
tap(state, RIGHT, 1000); tap(state, RIGHT, 1000);
state.cancel(); state.cancel();
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull(); // Without cancel() this would have been the seeking second tap.
expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
}); });
}); });
+52 -34
View File
@@ -1,16 +1,27 @@
/** /**
* Tap-gesture interpretation for the video player surface. * Tap-gesture interpretation for the video player surface.
* *
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable: * Every tap acts IMMEDIATELY there are only first and second taps, and no
* a tap cannot be classified at the moment it lands, because it may still turn * deferral:
* out to be the first half of a double tap. Play/pause is therefore *deferred*
* until the double-tap window closes, and cancelled outright if a second tap
* arrives otherwise a double tap both toggles pause and seeks.
* *
* TRACES: UR-005, UR-061 | DR-092, DR-095 | UT-085, UT-086, UT-087, UT-088 * 1st tap: toggle play/pause
* 2nd tap (within the window): seek, then toggle play/pause AGAIN
*
* The second toggle undoes the first, so a double tap seeks while leaving the
* play state exactly as it started playing stays playing, paused stays paused.
*
* This replaced a design that deferred the first tap behind a 300ms timer so it
* could be cancelled if a second tap arrived. That deferral raced the
* compatibility `click` Android's WebView synthesizes after a touch tap: the
* timer cleared its own handle *before* running the toggle, reopening the guard
* that was meant to suppress the late click, which then toggled a second time.
* The result was a play/pause loop about a second apart. Acting immediately
* removes the timer, the window race, and the loop.
*
* TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
*/ */
/** A second tap within this window makes a double tap. */ /** A second tap within this window pairs with the previous one (seek + re-toggle). */
export const DOUBLE_TAP_WINDOW_MS = 300; export const DOUBLE_TAP_WINDOW_MS = 300;
/** Double tap on the right half: skip forward. */ /** Double tap on the right half: skip forward. */
@@ -22,9 +33,18 @@ export const SEEK_BACKWARD_SECONDS = -10;
export type TapFeedback = "left" | "right"; export type TapFeedback = "left" | "right";
export type TapOutcome = export type TapOutcome =
/** Deferred: play/pause fires only if no second tap lands within the window. */ /** First tap: toggle play/pause right now. */
| { action: "pending"; pendingAfterMs: number } | { action: "togglePlayPause" }
| { action: "seek"; seekSeconds: number; feedback: TapFeedback }; /**
* Second tap: seek, and toggle play/pause again so the first tap's toggle is
* undone and the play state survives the double tap unchanged.
*/
| {
action: "seek";
seekSeconds: number;
feedback: TapFeedback;
togglePlayPause: true;
};
export interface TapInput { export interface TapInput {
/** Tap x position, viewport pixels. */ /** Tap x position, viewport pixels. */
@@ -35,32 +55,20 @@ export interface TapInput {
export interface TapGestureState { export interface TapGestureState {
/** /**
* Resolve a still-pending single tap. Returns the play/pause action once the * Forget the previous tap, so the next one is treated as a first tap. Used
* double-tap window has elapsed, or null if there is nothing pending (the tap * when the gesture turns out to be a swipe.
* became a double tap, or was cancelled).
*/ */
resolvePending(now: number): { action: "togglePlayPause" } | null;
/** Drop any pending tap — used when the gesture turns into a swipe. */
cancel(): void; cancel(): void;
} }
interface InternalState extends TapGestureState { interface InternalState extends TapGestureState {
lastTapTime: number; lastTapTime: number;
pendingSince: number | null;
} }
export function createTapGestureState(): TapGestureState { export function createTapGestureState(): TapGestureState {
const state: InternalState = { const state: InternalState = {
lastTapTime: 0, lastTapTime: 0,
pendingSince: null,
resolvePending(now: number) {
if (state.pendingSince === null) return null;
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
state.pendingSince = null;
return { action: "togglePlayPause" };
},
cancel() { cancel() {
state.pendingSince = null;
state.lastTapTime = 0; state.lastTapTime = 0;
}, },
}; };
@@ -68,27 +76,37 @@ export function createTapGestureState(): TapGestureState {
} }
/** /**
* Classify a tap. The first tap of a potential pair returns `pending` the * Classify a tap and return the action to perform *now*.
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside *
* the window returns the seek and clears the pending play/pause. * A tap that closely follows another is the second of a pair: it seeks and
* re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
* first tap and simply toggles. Nothing is deferred, so there is no window to
* race and no third-tap case a consumed pair resets the state.
*/ */
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome { export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
const s = state as InternalState; const s = state as InternalState;
const sinceLastTap = input.now - s.lastTapTime; const sinceLastTap = input.now - s.lastTapTime;
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) { if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
// Second tap: cancel the deferred play/pause and seek instead. s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
s.pendingSince = null;
s.lastTapTime = 0; // consumed, so a third tap starts fresh
const isLeftSide = input.x < input.screenWidth / 2; const isLeftSide = input.x < input.screenWidth / 2;
return isLeftSide return isLeftSide
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" } ? {
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" }; action: "seek",
seekSeconds: SEEK_BACKWARD_SECONDS,
feedback: "left",
togglePlayPause: true,
}
: {
action: "seek",
seekSeconds: SEEK_FORWARD_SECONDS,
feedback: "right",
togglePlayPause: true,
};
} }
s.lastTapTime = input.now; s.lastTapTime = input.now;
s.pendingSince = input.now; return { action: "togglePlayPause" };
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
} }
/** /**
+1 -1
View File
@@ -12,7 +12,7 @@
* derived + merged (remote-session-aware) stores so UI can import state and * derived + merged (remote-session-aware) stores so UI can import state and
* actions from one place, in both local and remote modes. * actions from one place, in both local and remote modes.
* *
* TRACES: UR-005 | DR-001, DR-009, DR-097 | UT-089 * TRACES: UR-005 | DR-001, DR-009, DR-097 | UT-091
*/ */
import { get } from "svelte/store"; import { get } from "svelte/store";
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview. * Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview.
* *
* TRACES: UR-005 | DR-097 | UT-089 * TRACES: UR-005 | DR-097 | UT-091
* *
* The frontend used to short-circuit transport controls whenever a video adapter * The frontend used to short-circuit transport controls whenever a video adapter
* was registered: `toggle()` read `el.paused` off the DOM and flipped the element * was registered: `toggle()` read `el.paused` off the DOM and flipped the element