diff --git a/docs/requirements.md b/docs/requirements.md index 78718d4e..6a52ee17 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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-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-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-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 | @@ -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-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-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-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-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-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-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** 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 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 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 diff --git a/package.json b/package.json index ac20164d..d44aba73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jellytau", - "version": "0.2.3", + "version": "0.2.4", "description": "", "type": "module", "packageManager": "bun@1.3.5", diff --git a/scripts/extract-traces.test.ts b/scripts/extract-traces.test.ts index f7737102..3b77b73e 100644 --- a/scripts/extract-traces.test.ts +++ b/scripts/extract-traces.test.ts @@ -175,8 +175,8 @@ describe("live requirements.md", () => { expect(defined.UR).toBe(61); expect(defined.IR).toBe(29); - expect(defined.DR).toBe(94); + expect(defined.DR).toBe(95); expect(defined.JA).toBe(32); - expect(defined.total).toBe(216); + expect(defined.total).toBe(217); }); }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f77363b0..be1231d3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "jellytau" -version = "0.2.3" +version = "0.2.4" dependencies = [ "aes-gcm", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5df2cc02..aa62ff68 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jellytau" -version = "0.2.3" +version = "0.2.4" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1edc6cad..5fb9dc32 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.3", + "version": "0.2.4", "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 143e7ed0..065cda8b 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -112,7 +112,10 @@ let touchStartY = $state(0); let touchStartTime = $state(0); let tapGestures = createTapGestureState(); - let tapTimeout: ReturnType | 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 showDoubleTapFeedback = $state(null); let doubleTapFeedbackTimeout: ReturnType | null = null; @@ -719,11 +722,6 @@ if (debugLogInterval) { clearInterval(debugLogInterval); } - // A deferred single tap must not fire play/pause after teardown. - if (tapTimeout) { - clearTimeout(tapTimeout); - tapTimeout = null; - } tapGestures.cancel(); if (doubleTapFeedbackTimeout) { clearTimeout(doubleTapFeedbackTimeout); @@ -1457,25 +1455,22 @@ now: Date.now(), }); - if (tapTimeout) { - clearTimeout(tapTimeout); - tapTimeout = null; - } + // Suppress the compatibility click this touch will synthesize. + lastTouchTapAt = Date.now(); if (outcome.action === "seek") { e.preventDefault(); 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; } - // Single tap so far: defer play/pause until the double-tap window closes, - // so a double tap seeks without also toggling pause. - tapTimeout = setTimeout(() => { - tapTimeout = null; - if (tapGestures.resolvePending(Date.now())) { - togglePlayPause(); - } - }, outcome.pendingAfterMs); + // First tap: act now. Nothing is deferred, so there is no timer to race the + // compatibility click Android synthesizes after a touch tap (see DR-098). + togglePlayPause(); } function handleTouchMove(e: TouchEvent) { @@ -1488,14 +1483,16 @@ // Minimum movement to register as swipe (50px) if (Math.abs(deltaY) > 50 && timeDelta > 50) { - swipeGestureActive = true; - - // This is a swipe, not a tap — drop the deferred play/pause. - tapGestures.cancel(); - if (tapTimeout) { - clearTimeout(tapTimeout); - tapTimeout = null; + // 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. + if (!swipeGestureActive) { + // The touchstart already toggled play/pause (taps act immediately now), + // so undo it: a swipe must not change the play state. Forget the tap too, + // so it cannot pair with a later tap into a spurious seek. + togglePlayPause(); + tapGestures.cancel(); } + swipeGestureActive = true; // Brightness control on vertical swipe swipeType = "brightness"; @@ -1514,14 +1511,19 @@ } /** - * Mouse clicks toggle play/pause immediately. Touch taps are already handled - * by `handleTouchStart` (which defers play/pause past the double-tap window), - * so the compatibility click that follows a tap must be ignored here — - * otherwise it pauses on the first tap of a double tap. + * 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. + * + * 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) { - // A click synthesized from a touch reports no pointer movement detail. - if (e.detail === 0 || tapTimeout !== null) return; + if (e.detail === 0) return; + if (Date.now() - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS) return; togglePlayPause(); } diff --git a/src/lib/components/player/tapGestures.test.ts b/src/lib/components/player/tapGestures.test.ts index cbb9dd8a..ef7a21e6 100644 --- a/src/lib/components/player/tapGestures.test.ts +++ b/src/lib/components/player/tapGestures.test.ts @@ -27,24 +27,22 @@ function asSeek(outcome: ReturnType) { } describe("tap gesture resolution", () => { - it("defers the single-tap action until the double-tap window has elapsed", () => { - const state = createTapGestureState(); - const first = tap(state, RIGHT, 1000); + // Every tap acts IMMEDIATELY — there is no deferral and no timer. + // + // 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 - // become a double tap. - expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS }); + it("toggles play/pause immediately on the first tap", () => { + const state = createTapGestureState(); + expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" }); }); - it("resolves an isolated tap to togglePlayPause once the window expires", () => { - 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", () => { + it("seeks forward 30s AND toggles again on a second right-side tap", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); 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(30); expect(second.feedback).toBe("right"); - - // The deferred single-tap pause must have been cancelled. - expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull(); + // The re-toggle is what preserves the play state across a double tap. + expect(second.togglePlayPause).toBe(true); }); - 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(); tap(state, LEFT, 1000); 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(-10); 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) => { + 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(); tap(state, RIGHT, 1000); 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(); tap(state, RIGHT, 1000); expect(tap(state, RIGHT, 1100).action).toBe("seek"); - // Triple tap: the third tap starts a fresh pending tap rather than - // seeking again off the consumed second tap. - expect(tap(state, RIGHT, 1200).action).toBe("pending"); + // The pair is consumed. The next tap is a FIRST tap again, so it toggles + // play/pause — there is no "third tap" concept. + expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause"); }); it("accumulates repeated double taps on the same side", () => { @@ -105,12 +122,13 @@ describe("tap gesture resolution", () => { 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(); tap(state, RIGHT, 1000); 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"); }); }); diff --git a/src/lib/components/player/tapGestures.ts b/src/lib/components/player/tapGestures.ts index 241d3c2e..5d9f8797 100644 --- a/src/lib/components/player/tapGestures.ts +++ b/src/lib/components/player/tapGestures.ts @@ -1,16 +1,27 @@ /** * Tap-gesture interpretation for the video player surface. * - * Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable: - * a tap cannot be classified at the moment it lands, because it may still turn - * 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. + * Every tap acts IMMEDIATELY — there are only first and second taps, and no + * deferral: * - * 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; /** 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 TapOutcome = - /** Deferred: play/pause fires only if no second tap lands within the window. */ - | { action: "pending"; pendingAfterMs: number } - | { action: "seek"; seekSeconds: number; feedback: TapFeedback }; + /** First tap: toggle play/pause right now. */ + | { action: "togglePlayPause" } + /** + * 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 { /** Tap x position, viewport pixels. */ @@ -35,32 +55,20 @@ export interface TapInput { export interface TapGestureState { /** - * Resolve a still-pending single tap. Returns the play/pause action once the - * double-tap window has elapsed, or null if there is nothing pending (the tap - * became a double tap, or was cancelled). + * Forget the previous tap, so the next one is treated as a first tap. Used + * when the gesture turns out to be a swipe. */ - resolvePending(now: number): { action: "togglePlayPause" } | null; - /** Drop any pending tap — used when the gesture turns into a swipe. */ cancel(): void; } interface InternalState extends TapGestureState { lastTapTime: number; - pendingSince: number | null; } export function createTapGestureState(): TapGestureState { const state: InternalState = { 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() { - state.pendingSince = null; state.lastTapTime = 0; }, }; @@ -68,27 +76,37 @@ export function createTapGestureState(): TapGestureState { } /** - * Classify a tap. The first tap of a potential pair returns `pending` — the - * caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside - * the window returns the seek and clears the pending play/pause. + * Classify a tap and return the action to perform *now*. + * + * 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 { const s = state as InternalState; const sinceLastTap = input.now - s.lastTapTime; if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) { - // Second tap: cancel the deferred play/pause and seek instead. - s.pendingSince = null; - s.lastTapTime = 0; // consumed, so a third tap starts fresh + s.lastTapTime = 0; // pair consumed; the next tap is a first tap again const isLeftSide = input.x < input.screenWidth / 2; 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.pendingSince = input.now; - return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS }; + return { action: "togglePlayPause" }; } /** diff --git a/src/lib/player/index.ts b/src/lib/player/index.ts index 49a50d2b..c22cb57f 100644 --- a/src/lib/player/index.ts +++ b/src/lib/player/index.ts @@ -12,7 +12,7 @@ * derived + merged (remote-session-aware) stores so UI can import state and * 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"; diff --git a/src/lib/player/transport.authority.test.ts b/src/lib/player/transport.authority.test.ts index 716cf389..3745ee67 100644 --- a/src/lib/player/transport.authority.test.ts +++ b/src/lib/player/transport.authority.test.ts @@ -1,7 +1,7 @@ /** * 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 * was registered: `toggle()` read `el.paused` off the DOM and flipped the element