diff --git a/packages/spf/src/media/live-window.ts b/packages/spf/src/media/live-window.ts new file mode 100644 index 00000000..25a76878 --- /dev/null +++ b/packages/spf/src/media/live-window.ts @@ -0,0 +1,49 @@ +/** + * Derive the live window of the selected video track — the single source of + * truth for "where is live," consumed by the seek-to-live-edge and + * live-seekable-range behaviors so neither re-derives (or re-presumes) the + * window shape. + * + * Returns `null` when there is no live edge to track: an unresolved + * presentation or track, a track with no segments, or a **complete** playlist + * (VoD, or live that has ended — a finite `Track.duration`). Video and audio + * share the timeline origin, so the video track's window positions both. + */ +import { + getMediaPlaylistMetadata, + isResolvedPresentation, + isResolvedTrack, + type MaybeResolvedPresentation, +} from './types'; +import { findTrack } from './utils/tracks'; + +export interface LiveWindow { + /** Earliest time still in the window (seconds, model timeline). */ + start: number; + /** The live edge — latest time in the window (seconds). */ + end: number; + /** Playlist target duration (seconds); falls back to the last segment's duration. */ + targetDuration: number; +} + +export function liveWindowFor( + presentation: MaybeResolvedPresentation | undefined, + videoTrackId: string | undefined +): LiveWindow | null { + if (!isResolvedPresentation(presentation) || !videoTrackId) return null; + + const track = findTrack(presentation, 'video', videoTrackId); + if (!track || !isResolvedTrack(track) || track.segments.length === 0) return null; + + // Complete playlist (VoD, or live that has ended) → no live edge. `Track.duration` + // is the parser's completeness signal (finite = complete, Infinity = still growing). + if (Number.isFinite(track.duration)) return null; + + const { segments } = track; + const last = segments[segments.length - 1]!; + return { + start: segments[0]!.startTime, + end: last.startTime + last.duration, + targetDuration: getMediaPlaylistMetadata(track)?.targetDuration || last.duration, + }; +} diff --git a/packages/spf/src/media/tests/live-window.test.ts b/packages/spf/src/media/tests/live-window.test.ts new file mode 100644 index 00000000..632deef7 --- /dev/null +++ b/packages/spf/src/media/tests/live-window.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { liveWindowFor } from '../live-window'; +import { MEDIA_PLAYLIST_METADATA_KEY, type Presentation, type VideoTrack } from '../types'; + +/** 5-segment, 2s window starting at 100: [100, 110]; targetDuration 2. */ +function makePresentation(overrides?: Partial): Presentation { + const video: VideoTrack = { + type: 'video', + id: 'v-1', + url: 'https://example.com/video.m3u8', + mimeType: 'video/mp4', + codecs: ['avc1.640020'], + bandwidth: 1_000_000, + initialization: { url: 'https://example.com/init.mp4' }, + duration: Number.POSITIVE_INFINITY, + startTime: 100, + startDate: 1000, + 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 } }, + ...overrides, + }; + return { + id: 'pres-1', + url: 'https://example.com/master.m3u8', + startTime: 0, + selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [video] }] }], + }; +} + +describe('liveWindowFor', () => { + it('returns the window + target duration for a live track', () => { + expect(liveWindowFor(makePresentation(), 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 }); + }); + + it('returns null for a complete (finite-duration) playlist — VoD / ended live', () => { + expect(liveWindowFor(makePresentation({ duration: 110 }), 'v-1')).toBeNull(); + }); + + it('returns null without a resolved presentation', () => { + expect(liveWindowFor(undefined, 'v-1')).toBeNull(); + expect(liveWindowFor({ id: 'p', url: 'https://example.com/x.m3u8' }, 'v-1')).toBeNull(); + }); + + it('returns null without a selected video track id', () => { + expect(liveWindowFor(makePresentation(), undefined)).toBeNull(); + }); + + it('returns null when the track id does not resolve', () => { + expect(liveWindowFor(makePresentation(), 'missing')).toBeNull(); + }); + + it('returns null for a track with no segments', () => { + expect(liveWindowFor(makePresentation({ segments: [] }), 'v-1')).toBeNull(); + }); + + it('falls back to the last segment duration when target duration is absent', () => { + const presentation = makePresentation({ + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, endList: false } }, + }); + expect(liveWindowFor(presentation, 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 }); + }); +}); 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 a7e56bf4..95dbf098 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 @@ -1,45 +1,31 @@ /** - * Enter and hold the live window: declare the seekable range, seek the playhead - * in, and reposition it to the live edge if it ever falls outside the window. + * Seek the playhead into the live window and keep it there: * - * Live segments append at their native PTS, so the buffered window sits at a - * large timestamp while `currentTime` starts at 0. Two problems follow, both - * solved here from the *model* (not from `buffered`, which is empty at - * cold-start — the segment loader only fetches segments overlapping - * `[currentTime, currentTime + bufferDuration]`, so until the playhead is in - * the window nothing loads): + * 1. A one-time seek of `currentTime` to HOLD-BACK behind the live edge + * (default 3 × TARGETDURATION, clamped to the window start) so playback + * begins near the edge and the segment loader dispatches an in-window range + * rather than starting at the back of the DVR window. + * 2. A live-window playhead guard: while playing (`!paused && !seeking && + * readyState > 0`), reposition `currentTime` to the live edge when it falls + * *outside* the sliding window — a paused playhead the window slid past + * (caught on the `playing` resume) or playback that fell behind on poor + * network (caught on this effect's window-update re-fire, since `timeupdate` + * stops once a stall freezes `currentTime`). In-window pause and DVR + * scrub-back are left untouched (the `window-exit` reposition policy). * - * 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 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. - * 3. A live-window playhead guard: while playing, reposition `currentTime` to - * the live edge when it falls *outside* the sliding window — a paused - * playhead the window slid past (caught on the `playing` resume), or - * playback that fell behind on poor network (caught on this effect's - * window-update re-fire, since `timeupdate` stops once a stall freezes - * `currentTime`). In-window pause and DVR scrub-back are left untouched (the - * `window-exit` reposition policy — the DVR model). - * - * Reads the *selected video track* timeline (anchored to ≈ native PTS by - * `anchorLiveTracks`); video and audio share the origin, so the video window - * positions both. Seeks once per source; re-declares the seekable range on - * each window change. + * The live window comes from `liveWindowFor` (the shared derivation); this + * behavior is inert when it returns `null` (VoD / ended live). Declaring the + * seekable range is a separate concern — see `sync-live-seekable-range`, which + * is composed *before* this behavior so the range exists before we seek into + * it (a seek outside `seekable` is clamped). The `mediaSource` open-gate here + * is the read that ties the seek to that declared range being available. */ import { listen } from '@videojs/utils/dom'; import type { Behavior } from '../../../core/composition/create-composition'; import { effect } from '../../../core/signals/effect'; import type { ReadonlySignal } from '../../../core/signals/primitives'; -import { - getMediaPlaylistMetadata, - isResolvedPresentation, - isResolvedTrack, - type MaybeResolvedPresentation, -} from '../../../media/types'; -import { findTrack } from '../../../media/utils/tracks'; +import { liveWindowFor } from '../../../media/live-window'; +import type { MaybeResolvedPresentation } from '../../../media/types'; /** * Multiple of TARGETDURATION to start behind the live edge — the HLS spec @@ -98,40 +84,13 @@ function seekToLiveEdgeSetup({ return effect(() => { const mediaElement = context.mediaElement.get(); const mediaSource = context.mediaSource.get(); - const presentation = state.presentation.get(); - const trackId = state.selectedVideoTrackId?.get(); - if (!mediaElement || !mediaSource || !isResolvedPresentation(presentation) || !trackId) return; - if (mediaSource.readyState !== 'open') return; + const liveWindow = liveWindowFor(state.presentation.get(), state.selectedVideoTrackId?.get()); + if (!mediaElement || !liveWindow) return; + // Gate on the seekable range being declarable/declared (see file JSDoc): + // sync-live-seekable-range runs first while open, so seeks land in-window. + if (!mediaSource || mediaSource.readyState !== 'open') return; - const track = findTrack(presentation, 'video', trackId); - if (!track || !isResolvedTrack(track) || track.segments.length === 0) return; - - // Complete playlist (VoD, or live that has ended) → no live edge to seek to. - // `Track.duration` is the parser's completeness signal (finite = complete, - // Infinity = still growing / live); keeps this behavior inert for VoD in the - // unified engine — a VoD source never declares a live seekable range or seeks. - if (Number.isFinite(track.duration)) return; - - const { segments } = track; - const windowStart = segments[0]!.startTime; - const last = segments[segments.length - 1]!; - const windowEnd = last.startTime + last.duration; - - 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 - // (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; - } - - // 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 { start: windowStart, end: windowEnd, targetDuration } = liveWindow; const liveEdgeStart = Math.max(windowStart, windowEnd - HOLD_BACK_TARGET_MULTIPLIER * targetDuration); // Initial entry: seek into the window once — even while paused — so the diff --git a/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts b/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts new file mode 100644 index 00000000..01b52e2b --- /dev/null +++ b/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts @@ -0,0 +1,79 @@ +/** + * Mirror the live window into the MediaSource's seekable range. On every + * window update — **including while paused** (the seekable range must stay + * current as the window slides, regardless of play state) — declare + * `setLiveSeekableRange(start, end)` so the browser's `HTMLMediaElement.seekable` + * reflects the live window (without it, `seekable` is empty under + * `duration === Infinity`). + * + * The live window comes from `liveWindowFor` (the shared derivation); inert + * when it returns `null` (VoD / ended live). Composed *before* `seekToLiveEdge` + * so the range is declared before that behavior seeks the playhead into it (a + * seek outside `seekable` is clamped). + * + * Out of scope (deliberate, tracked as follow-ups): `clearLiveSeekableRange()` + * on the live→ended transition; and dropping the defensive `duration` write + * below once duration ownership/ordering with `updateMediaSourceDuration` (the + * canonical, but asynchronous, duration writer) is resolved. + */ +import type { Behavior } from '../../../core/composition/create-composition'; +import { effect } from '../../../core/signals/effect'; +import type { ReadonlySignal } from '../../../core/signals/primitives'; +import { liveWindowFor } from '../../../media/live-window'; +import type { MaybeResolvedPresentation } from '../../../media/types'; + +export interface SyncLiveSeekableRangeState { + presentation?: MaybeResolvedPresentation; + selectedVideoTrackId?: string; +} + +export interface SyncLiveSeekableRangeContext { + mediaSource?: MediaSource; +} + +function syncLiveSeekableRangeSetup({ + state, + context, +}: { + state: { + presentation: ReadonlySignal; + selectedVideoTrackId?: ReadonlySignal; + }; + context: { + mediaSource: ReadonlySignal; + }; +}): () => void { + return effect(() => { + const mediaSource = context.mediaSource.get(); + const liveWindow = liveWindowFor(state.presentation.get(), state.selectedVideoTrackId?.get()); + if (!mediaSource || mediaSource.readyState !== 'open' || !liveWindow) return; + + try { + // A live seekable range needs a set duration. `updateMediaSourceDuration` + // is the canonical duration owner, but writes asynchronously — so guard + // here so this doesn't race ahead of it. (Follow-up: resolve ownership.) + if (Number.isNaN(mediaSource.duration)) mediaSource.duration = Number.POSITIVE_INFINITY; + // Re-declared as the window slides so seekable tracks the live window + // (the full DVR range remains seekable; seek-to-live-edge starts near the edge). + mediaSource.setLiveSeekableRange(liveWindow.start, liveWindow.end); + } catch { + // readyState raced closed, or duration set rejected — retried on the next window change. + } + }); +} + +/** + * Manual `Behavior<>` literal (like `seekToLiveEdge`): declares only + * `presentation` in stateKeys while reading `selectedVideoTrackId` defensively + * (contributed by `switchVideoTrack`), so it composes without a + * stateKeys/type conflict. + */ +export const syncLiveSeekableRange: Behavior< + { presentation: ReadonlySignal }, + { mediaSource: ReadonlySignal }, + object +> = { + stateKeys: ['presentation'], + contextKeys: ['mediaSource'], + setup: syncLiveSeekableRangeSetup, +}; 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 8e5f5460..ee244717 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 @@ -98,28 +98,24 @@ function run(opts: { const flush = () => Promise.resolve(); describe('seekToLiveEdge', () => { - it('declares the full seekable window and seeks near the live edge (HOLD-BACK behind)', () => { + it('seeks near the live edge on entry (HOLD-BACK behind)', () => { const ms = fakeMediaSource(); const el = fakeMediaElement(); const { cleanup } = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms }); - // 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); // Start HOLD-BACK (3 × 2s) behind the edge: 110 − 6 = 104, not the window start. expect(el.currentTime).toBe(104); cleanup(); }); - it('does nothing until the MediaSource is open', () => { + it('does not seek until the MediaSource is open', () => { const ms = fakeMediaSource('closed'); const el = fakeMediaElement(); const { cleanup } = run({ presentation: makePresentation(), trackId: 'v-1', mediaElement: el, mediaSource: ms }); - expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); expect(el.currentTime).toBe(0); cleanup(); @@ -136,7 +132,6 @@ describe('seekToLiveEdge', () => { const { cleanup } = run({ presentation, trackId: 'v-1', mediaElement: el, mediaSource: ms }); - expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); expect(el.currentTime).toBe(0); cleanup(); @@ -148,7 +143,6 @@ describe('seekToLiveEdge', () => { const { cleanup } = run({ presentation: undefined, trackId: undefined, mediaElement: el, mediaSource: ms }); - expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); expect(el.currentTime).toBe(0); cleanup(); diff --git a/packages/spf/src/playback/behaviors/dom/tests/sync-live-seekable-range.test.ts b/packages/spf/src/playback/behaviors/dom/tests/sync-live-seekable-range.test.ts new file mode 100644 index 00000000..3826b9cf --- /dev/null +++ b/packages/spf/src/playback/behaviors/dom/tests/sync-live-seekable-range.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; +import { signal } from '../../../../core/signals/primitives'; +import { + type MaybeResolvedPresentation, + MEDIA_PLAYLIST_METADATA_KEY, + type Presentation, + type VideoTrack, +} from '../../../../media/types'; +import { syncLiveSeekableRange } from '../sync-live-seekable-range'; + +function makePresentation(): Presentation { + // 5-segment, 2s window: [100, 110]. + const video: VideoTrack = { + type: 'video', + id: 'v-1', + url: 'https://example.com/video.m3u8', + mimeType: 'video/mp4', + codecs: ['avc1.640020'], + bandwidth: 1_000_000, + initialization: { url: 'https://example.com/init.mp4' }, + duration: Number.POSITIVE_INFINITY, + startTime: 100, + startDate: 1000, + 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', + url: 'https://example.com/master.m3u8', + startTime: 0, + selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [video] }] }], + }; +} + +function fakeMediaSource(readyState: MediaSource['readyState'] = 'open') { + return { + readyState, + duration: Number.NaN, + setLiveSeekableRange: vi.fn(), + } as unknown as MediaSource & { setLiveSeekableRange: ReturnType }; +} + +function run(opts: { presentation?: MaybeResolvedPresentation; trackId?: string; mediaSource?: MediaSource }) { + const state = { + presentation: signal(opts.presentation), + selectedVideoTrackId: signal(opts.trackId), + }; + const context = { mediaSource: signal(opts.mediaSource) }; + return syncLiveSeekableRange.setup({ state, context, config: {} }) as () => void; +} + +describe('syncLiveSeekableRange', () => { + it('declares the full live window as seekable and sets Infinity duration', () => { + const ms = fakeMediaSource(); + const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms }); + + // [first.startTime, last.startTime + last.duration] = [100, 110]. + expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110); + expect(ms.duration).toBe(Number.POSITIVE_INFINITY); + + cleanup(); + }); + + it('does nothing until the MediaSource is open', () => { + const ms = fakeMediaSource('closed'); + const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms }); + + expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('no-ops for a complete (finite-duration) playlist — VoD / ended live', () => { + const ms = fakeMediaSource(); + const presentation = makePresentation(); + (presentation.selectionSets[0]!.switchingSets[0]!.tracks[0] as VideoTrack).duration = 110; + + const cleanup = run({ presentation, trackId: 'v-1', mediaSource: ms }); + + expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('no-ops without a resolved presentation or selected track', () => { + const ms = fakeMediaSource(); + const cleanup = run({ presentation: undefined, trackId: undefined, mediaSource: ms }); + + expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('does not overwrite an already-set (non-NaN) duration', () => { + const ms = fakeMediaSource(); + ms.duration = 500; // e.g. updateMediaSourceDuration already wrote it + const cleanup = run({ presentation: makePresentation(), trackId: 'v-1', mediaSource: ms }); + + expect(ms.duration).toBe(500); + expect(ms.setLiveSeekableRange).toHaveBeenCalledWith(100, 110); + + cleanup(); + }); +}); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 85e2d63d..92720c12 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -48,6 +48,7 @@ import { seekToLiveEdge } from '../../behaviors/dom/seek-to-live-edge'; import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors'; import { setupMediaSource } from '../../behaviors/dom/setup-mediasource'; import { setupTextTrackActors } from '../../behaviors/dom/setup-text-track-actors'; +import { syncLiveSeekableRange } from '../../behaviors/dom/sync-live-seekable-range'; import { syncTextTracks } from '../../behaviors/dom/sync-text-tracks'; import { trackCurrentTime } from '../../behaviors/dom/track-current-time'; import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers'; @@ -426,8 +427,12 @@ export function createSimpleHlsEngine( loadVideoSegments, loadAudioSegments, - // Live: declare the seekable window and seek the playhead to the live - // edge once segments land. No-op for complete playlists (VoD / ended). + // Live: declare the seekable window, then seek the playhead to the live + // edge + keep it in-window. No-op for complete playlists (VoD / ended). + // Order matters: the seekable range must be declared (syncLiveSeekableRange) + // before seekToLiveEdge moves the playhead into it (a seek outside + // `seekable` is clamped). + syncLiveSeekableRange, seekToLiveEdge, // End of stream coordination