diff --git a/docs/requirements.md b/docs/requirements.md index 04f9a0a7..d7ef42a3 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -246,8 +246,9 @@ Internal architecture, components, and application logic. | DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done | | DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | 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 to `[0, duration]` 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-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-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done | --- diff --git a/scripts/extract-traces.test.ts b/scripts/extract-traces.test.ts index 9f2988a6..4f730485 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(91); + expect(defined.DR).toBe(92); expect(defined.JA).toBe(32); - expect(defined.total).toBe(213); + expect(defined.total).toBe(214); }); }); diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index 2977de7f..6ac710ea 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -24,6 +24,7 @@ createTapGestureState, registerTap, resolveSeekTarget, + clampSeekTarget, SEEK_FORWARD_SECONDS, SEEK_BACKWARD_SECONDS, type TapFeedback, @@ -1147,7 +1148,10 @@ async function handleSeekBarChange(e: Event) { const input = e.target as HTMLInputElement; - const targetTime = parseFloat(input.value); + // Clamp strictly inside the media: the range input's max IS the duration, so + // dragging fully right would otherwise request a segment past the media end, + // which the server never produces (see END_SEEK_MARGIN_SECONDS). + const targetTime = clampSeekTarget(parseFloat(input.value), duration); // Set isSeeking immediately to prevent timeupdate from interfering isSeeking = true; diff --git a/src/lib/components/player/tapGestures.test.ts b/src/lib/components/player/tapGestures.test.ts index 26abf2c5..cbb9dd8a 100644 --- a/src/lib/components/player/tapGestures.test.ts +++ b/src/lib/components/player/tapGestures.test.ts @@ -6,6 +6,8 @@ import { createTapGestureState, registerTap, resolveSeekTarget, + clampSeekTarget, + END_SEEK_MARGIN_SECONDS, } from "./tapGestures"; const SCREEN_WIDTH = 1000; @@ -123,8 +125,28 @@ describe("seek target resolution", () => { expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0); }); - it("clamps to the duration when skipping past the end", () => { - expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION); + it("clamps short of the duration when skipping past the end", () => { + // Never land exactly on `duration`: hls.js would then request the segment + // that starts at/after the media end, which the server never produces — + // the fetch times out and the gap-controller stalls in a pause loop. + expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe( + DURATION - END_SEEK_MARGIN_SECONDS + ); + }); + + it("keeps the end clamp strictly inside the media for a long transcoded item", () => { + // Regression: seeking near the end of a ~105min transcoded item clamped to + // the exact runtime (6330.324s), making hls.js fetch segment 1055 which + // starts at 6336.33s — past the end. That segment 404s/times out forever. + const runtime = 6330.324; + const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime }); + + expect(target).toBeLessThan(runtime); + expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5); + }); + + it("does not clamp below zero for media shorter than the end margin", () => { + expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0); }); it("chains off a pending target so rapid taps do not compound off a stale position", () => { @@ -156,3 +178,24 @@ describe("seek target resolution", () => { expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130); }); }); + +describe("seek target clamping (shared by skip and seek-bar drag)", () => { + it("keeps a mid-stream target untouched", () => { + expect(clampSeekTarget(100, 600)).toBe(100); + }); + + it("pulls a drag to the very end back inside the media", () => { + // The seek bar's max IS the duration, so dragging fully right yields + // exactly `duration` — the value that triggers the dead-segment stall. + expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5); + }); + + it("clamps negative and non-finite targets to zero", () => { + expect(clampSeekTarget(-5, 600)).toBe(0); + expect(clampSeekTarget(NaN, 600)).toBe(0); + }); + + it("leaves the target alone when the duration is unknown", () => { + expect(clampSeekTarget(500, 0)).toBe(500); + }); +}); diff --git a/src/lib/components/player/tapGestures.ts b/src/lib/components/player/tapGestures.ts index 308f9b94..241d3c2e 100644 --- a/src/lib/components/player/tapGestures.ts +++ b/src/lib/components/player/tapGestures.ts @@ -7,7 +7,7 @@ * 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 | UT-085, UT-086, UT-087, UT-088 + * TRACES: UR-005, UR-061 | DR-092, DR-095 | UT-085, UT-086, UT-087, UT-088 */ /** A second tap within this window makes a double tap. */ @@ -91,6 +91,33 @@ export function registerTap(state: TapGestureState, input: TapInput): TapOutcome return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS }; } +/** + * Safety margin (seconds) kept between a clamped seek target and the media end. + * + * Landing *exactly* on `duration` makes hls.js request the segment whose start + * time is at/after the end of the media. The server never produces that segment, + * so the fetch times out and hls.js' gap-controller stalls forever at the last + * buffered position — surfacing as "unpausing bounces straight back to paused". + * One segment length (~6s for Jellyfin's ts segments) is comfortably clear of + * the final segment boundary. + */ +export const END_SEEK_MARGIN_SECONDS = 6; + +/** + * Clamp an absolute seek target into the safely-playable range. + * + * Shared by the relative-skip path ({@link resolveSeekTarget}) and the seek-bar + * drag path, which can otherwise land exactly on `duration` because the range + * input's `max` is the duration itself. + */ +export function clampSeekTarget(target: number, duration: number): number { + if (!Number.isFinite(target) || target < 0) return 0; + if (duration > 0 && target > duration - END_SEEK_MARGIN_SECONDS) { + return Math.max(0, duration - END_SEEK_MARGIN_SECONDS); + } + return target; +} + export interface SeekTargetInput { /** Relative offset in seconds (negative rewinds). */ delta: number; @@ -123,6 +150,10 @@ export function resolveSeekTarget(input: SeekTargetInput): number { const target = base + delta; if (target < 0) return 0; - if (duration > 0 && target > duration) return duration; + // Clamp strictly inside the media — see END_SEEK_MARGIN_SECONDS. Guard against + // going negative on media shorter than the margin itself. + if (duration > 0 && target > duration) { + return Math.max(0, duration - END_SEEK_MARGIN_SECONDS); + } return target; }