From cf69a7b7ebc1d5fea93dd3b862142c249223a7e2 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 15 Jun 2026 14:41:37 -0700 Subject: [PATCH] feat(spf): start live playback near the edge (HOLD-BACK) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seekToLiveEdge seeked to the window start (~full DVR window behind live). Start HOLD-BACK behind the edge instead — windowEnd − 3 × TARGETDURATION (the HLS spec default when the playlist omits HOLD-BACK), clamped to the window start. The full DVR window stays seekable; only the initial position moves toward the edge. Verified against a live Mux CMAF stream: starts ~edge instead of ~20s back. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../behaviors/dom/seek-to-live-edge.ts | 34 +++++++++++++++---- .../dom/tests/seek-to-live-edge.test.ts | 30 ++++++++++------ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts index 6a153a7a..7e419cad 100644 --- a/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts +++ b/packages/spf/src/playback/behaviors/dom/seek-to-live-edge.ts @@ -11,8 +11,10 @@ * 1. `MediaSource.setLiveSeekableRange(windowStart, windowEnd)` — derived from * the selected video track's anchored segment timeline — so the window is * seekable (kept current as the window slides across reloads). - * 2. A one-time seek of `currentTime` to the window start, so the loader - * dispatches an in-window range and playback can begin. + * 2. A one-time seek of `currentTime` to HOLD-BACK behind the live edge + * (default 3 × TARGETDURATION, clamped to the window start), so the loader + * dispatches an in-window range and playback can begin near the edge rather + * than at the back of the DVR window. * * Reads the *selected video track* timeline (anchored to ≈ native PTS by * `anchorLiveTracks`); video and audio share the origin, so the video window @@ -22,9 +24,20 @@ import type { Behavior } from '../../../core/composition/create-composition'; import { effect } from '../../../core/signals/effect'; import type { ReadonlySignal } from '../../../core/signals/primitives'; -import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation } from '../../../media/types'; +import { + getMediaPlaylistMetadata, + isResolvedPresentation, + isResolvedTrack, + type MaybeResolvedPresentation, +} from '../../../media/types'; import { findTrack } from '../../../media/utils/tracks'; +/** + * Multiple of TARGETDURATION to start behind the live edge — the HLS spec + * default for HOLD-BACK when the playlist doesn't specify one (RFC 8216bis). + */ +const HOLD_BACK_TARGET_MULTIPLIER = 3; + export interface SeekToLiveEdgeState { presentation?: MaybeResolvedPresentation; selectedVideoTrackId?: string; @@ -69,16 +82,23 @@ function seekToLiveEdgeSetup({ try { // Live duration is unbounded; required for a live seekable range. if (Number.isNaN(mediaSource.duration)) mediaSource.duration = Number.POSITIVE_INFINITY; - // Re-declared as the window slides so seekable tracks the live window. + // Re-declared as the window slides so seekable tracks the live window + // (the full DVR range remains seekable; we just start near the edge). mediaSource.setLiveSeekableRange(windowStart, windowEnd); } catch { // readyState raced closed, or duration set rejected — retried on the next window change. return; } - // Seek into the window once so the loader dispatches an in-window range. - if (!seeked && mediaElement.currentTime < windowStart) { - mediaElement.currentTime = windowStart; + // Start near the live edge: HOLD-BACK (default 3 × TARGETDURATION) behind it, + // clamped to the window start. Closer to live than the window start, while + // leaving enough buffered ahead to begin smoothly. + const targetDuration = getMediaPlaylistMetadata(track)?.targetDuration || last.duration; + const liveEdgeStart = Math.max(windowStart, windowEnd - HOLD_BACK_TARGET_MULTIPLIER * targetDuration); + + // Seek to the live edge once, so the loader dispatches an in-window range. + if (!seeked && mediaElement.currentTime < liveEdgeStart) { + mediaElement.currentTime = liveEdgeStart; seeked = true; } }); diff --git a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts index 6ff2c89b..eca1b594 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; import { signal } from '../../../../core/signals/primitives'; -import type { MaybeResolvedPresentation, Presentation, VideoTrack } from '../../../../media/types'; +import { + type MaybeResolvedPresentation, + MEDIA_PLAYLIST_METADATA_KEY, + type Presentation, + type VideoTrack, +} from '../../../../media/types'; import { seekToLiveEdge } from '../seek-to-live-edge'; function makePresentation(): Presentation { + // 5-segment, 2s window: [100, 110]. HOLD-BACK = 3 × targetDuration(2) = 6, + // so the live-edge start is 110 − 6 = 104. const video: VideoTrack = { type: 'video', id: 'v-1', @@ -15,11 +22,13 @@ function makePresentation(): Presentation { duration: Number.POSITIVE_INFINITY, startTime: 100, startDate: 1000, - segments: [ - { id: 'segment-50', url: '50.m4s', duration: 2, startTime: 100 }, - { id: 'segment-51', url: '51.m4s', duration: 2, startTime: 102 }, - { id: 'segment-52', url: '52.m4s', duration: 2, startTime: 104 }, - ], + segments: [100, 102, 104, 106, 108].map((startTime, i) => ({ + id: `segment-${50 + i}`, + url: `${50 + i}.m4s`, + duration: 2, + startTime, + })), + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, targetDuration: 2, endList: false } }, }; return { id: 'pres-1', @@ -58,16 +67,17 @@ function run(opts: { } describe('seekToLiveEdge', () => { - it('declares the live seekable range and seeks into the window', () => { + it('declares the full seekable window and seeks near the live edge (HOLD-BACK behind)', () => { const ms = fakeMediaSource(); const el = { currentTime: 0 } as HTMLMediaElement; const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms }); - // window = [first.startTime, last.startTime + last.duration] = [100, 106]. - expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 106); + // Full DVR window stays seekable: [first.startTime, last.startTime + last.duration] = [100, 110]. + expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110); expect(ms.duration).toBe(Number.POSITIVE_INFINITY); - expect(el.currentTime).toBe(100); + // Start HOLD-BACK (3 × 2s) behind the edge: 110 − 6 = 104, not the window start. + expect(el.currentTime).toBe(104); cleanup(); });