From 0a7187eb2265745c6a743a19e57ccdb2783a909f Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 23 Jun 2026 12:26:56 -0700 Subject: [PATCH] fix(spf): derive the live window for audio-only sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit liveWindowFor hardcoded the selected *video* track, so audio-only live (no video track / no selectedVideoTrackId) derived no window — leaving both seek-to-live-edge and sync-live-seekable-range inert. Make liveWindowFor track-type-agnostic (findTrackById instead of findTrack('video')), and add a liveWindowFromState primitive that picks the timeline-bearing track: selectedVideoTrackId ?? selectedAudioTrackId (video positions both A/V; audio-only falls back to audio). Both behaviors now share that single call site, removing the two brittle, must-stay-identical liveWindowFor(...) call sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spf/src/media/live-window.ts | 20 ++-- .../spf/src/media/tests/live-window.test.ts | 32 ++++- .../behaviors/dom/seek-to-live-edge.ts | 11 +- .../behaviors/dom/sync-live-seekable-range.ts | 11 +- .../src/playback/primitives/live-window.ts | 27 +++++ .../primitives/tests/live-window.test.ts | 111 ++++++++++++++++++ 6 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 packages/spf/src/playback/primitives/live-window.ts create mode 100644 packages/spf/src/playback/primitives/tests/live-window.test.ts diff --git a/packages/spf/src/media/live-window.ts b/packages/spf/src/media/live-window.ts index 25a76878..94e9b7c0 100644 --- a/packages/spf/src/media/live-window.ts +++ b/packages/spf/src/media/live-window.ts @@ -1,13 +1,13 @@ /** - * 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. + * Derive the live window of the track with the given id — the single source of + * truth for "where is live," consumed (via `liveWindowFromState`) by the + * seek-to-live-edge and live-seekable-range behaviors so neither re-derives (or + * re-presumes) the window shape. Type-agnostic: the caller decides which track + * bears the timeline (video when present, else audio). * * 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. + * (VoD, or live that has ended — a finite `Track.duration`). */ import { getMediaPlaylistMetadata, @@ -15,7 +15,7 @@ import { isResolvedTrack, type MaybeResolvedPresentation, } from './types'; -import { findTrack } from './utils/tracks'; +import { findTrackById } from './utils/tracks'; export interface LiveWindow { /** Earliest time still in the window (seconds, model timeline). */ @@ -28,11 +28,11 @@ export interface LiveWindow { export function liveWindowFor( presentation: MaybeResolvedPresentation | undefined, - videoTrackId: string | undefined + trackId: string | undefined ): LiveWindow | null { - if (!isResolvedPresentation(presentation) || !videoTrackId) return null; + if (!isResolvedPresentation(presentation) || !trackId) return null; - const track = findTrack(presentation, 'video', videoTrackId); + const track = findTrackById(presentation, trackId); if (!track || !isResolvedTrack(track) || track.segments.length === 0) return null; // Complete playlist (VoD, or live that has ended) → no live edge. `Track.duration` diff --git a/packages/spf/src/media/tests/live-window.test.ts b/packages/spf/src/media/tests/live-window.test.ts index 632deef7..89a4194e 100644 --- a/packages/spf/src/media/tests/live-window.test.ts +++ b/packages/spf/src/media/tests/live-window.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { liveWindowFor } from '../live-window'; -import { MEDIA_PLAYLIST_METADATA_KEY, type Presentation, type VideoTrack } from '../types'; +import { type AudioTrack, 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 { @@ -64,4 +64,34 @@ describe('liveWindowFor', () => { }); expect(liveWindowFor(presentation, 'v-1')).toEqual({ start: 100, end: 110, targetDuration: 2 }); }); + + it('resolves a track by id regardless of type — audio-only', () => { + const audio: AudioTrack = { + type: 'audio', + id: 'a-1', + groupId: 'audio-hi', + name: 'Default', + sampleRate: 48_000, + channels: 2, + url: 'https://example.com/audio.m3u8', + mimeType: 'audio/mp4', + codecs: ['mp4a.40.2'], + bandwidth: 128_000, + initialization: { url: 'https://example.com/a-init.mp4' }, + duration: Number.POSITIVE_INFINITY, + startTime: 200, + startDate: 1000, + segments: [0, 2, 4, 6, 8].map((o, i) => ({ id: `a-${i}`, url: `a${i}.m4s`, duration: 2, startTime: 200 + o })), + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 2, endList: false } }, + }; + const presentation: Presentation = { + id: 'pres-1', + url: 'https://example.com/master.m3u8', + startTime: 0, + selectionSets: [ + { id: 'audio-set', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: [audio] }] }, + ], + }; + expect(liveWindowFor(presentation, 'a-1')).toEqual({ start: 200, end: 210, 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 95dbf098..8c35ec8b 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 @@ -13,8 +13,9 @@ * stops once a stall freezes `currentTime`). In-window pause and DVR * scrub-back are left untouched (the `window-exit` reposition policy). * - * The live window comes from `liveWindowFor` (the shared derivation); this - * behavior is inert when it returns `null` (VoD / ended live). Declaring the + * The live window comes from `liveWindowFromState` (the shared derivation — + * video track when present, else audio); 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 @@ -24,8 +25,8 @@ 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 { liveWindowFor } from '../../../media/live-window'; import type { MaybeResolvedPresentation } from '../../../media/types'; +import { liveWindowFromState } from '../../primitives/live-window'; /** * Multiple of TARGETDURATION to start behind the live edge — the HLS spec @@ -51,6 +52,7 @@ export type LiveRepositionPolicy = 'window-exit' | 'on-resume'; export interface SeekToLiveEdgeState { presentation?: MaybeResolvedPresentation; selectedVideoTrackId?: string; + selectedAudioTrackId?: string; } export interface SeekToLiveEdgeContext { @@ -71,6 +73,7 @@ function seekToLiveEdgeSetup({ state: { presentation: ReadonlySignal; selectedVideoTrackId?: ReadonlySignal; + selectedAudioTrackId?: ReadonlySignal; }; context: { mediaElement: ReadonlySignal; @@ -84,7 +87,7 @@ function seekToLiveEdgeSetup({ return effect(() => { const mediaElement = context.mediaElement.get(); const mediaSource = context.mediaSource.get(); - const liveWindow = liveWindowFor(state.presentation.get(), state.selectedVideoTrackId?.get()); + const liveWindow = liveWindowFromState(state); 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. 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 index 6b71efd6..50fedebd 100644 --- a/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts +++ b/packages/spf/src/playback/behaviors/dom/sync-live-seekable-range.ts @@ -6,8 +6,9 @@ * 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` + * The live window comes from `liveWindowFromState` (the shared derivation — + * video track when present, else audio); 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). * @@ -20,12 +21,13 @@ 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'; +import { liveWindowFromState } from '../../primitives/live-window'; export interface SyncLiveSeekableRangeState { presentation?: MaybeResolvedPresentation; selectedVideoTrackId?: string; + selectedAudioTrackId?: string; } export interface SyncLiveSeekableRangeContext { @@ -39,6 +41,7 @@ function syncLiveSeekableRangeSetup({ state: { presentation: ReadonlySignal; selectedVideoTrackId?: ReadonlySignal; + selectedAudioTrackId?: ReadonlySignal; }; context: { mediaSource: ReadonlySignal; @@ -46,7 +49,7 @@ function syncLiveSeekableRangeSetup({ }): () => void { return effect(() => { const mediaSource = context.mediaSource.get(); - const liveWindow = liveWindowFor(state.presentation.get(), state.selectedVideoTrackId?.get()); + const liveWindow = liveWindowFromState(state); if (!mediaSource || mediaSource.readyState !== 'open' || !liveWindow) return; // Re-declared as the window slides so seekable tracks the live window diff --git a/packages/spf/src/playback/primitives/live-window.ts b/packages/spf/src/playback/primitives/live-window.ts new file mode 100644 index 00000000..485fa061 --- /dev/null +++ b/packages/spf/src/playback/primitives/live-window.ts @@ -0,0 +1,27 @@ +/** + * Resolve the live window from engine state — the single call site the + * seek-to-live-edge and live-seekable-range behaviors share, so their window + * derivation can't drift apart. + * + * Picks the timeline-bearing track: the selected **video** track when present, + * else the selected **audio** track (audio-only sources). Video and audio share + * the timeline origin, so the video window positions both; audio-only has no + * video track, so the audio window is authoritative. + * + * Reads signals lazily — call it inside a reactive context (an effect) so the + * read tracks `presentation` + the selected-track ids. + */ +import type { ReadonlySignal } from '../../core/signals/primitives'; +import { type LiveWindow, liveWindowFor } from '../../media/live-window'; +import type { MaybeResolvedPresentation } from '../../media/types'; + +export interface LiveWindowState { + presentation: ReadonlySignal; + selectedVideoTrackId?: ReadonlySignal; + selectedAudioTrackId?: ReadonlySignal; +} + +export function liveWindowFromState(state: LiveWindowState): LiveWindow | null { + const trackId = state.selectedVideoTrackId?.get() ?? state.selectedAudioTrackId?.get(); + return liveWindowFor(state.presentation.get(), trackId); +} diff --git a/packages/spf/src/playback/primitives/tests/live-window.test.ts b/packages/spf/src/playback/primitives/tests/live-window.test.ts new file mode 100644 index 00000000..32a8ec07 --- /dev/null +++ b/packages/spf/src/playback/primitives/tests/live-window.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { signal } from '../../../core/signals/primitives'; +import { + type AudioSelectionSet, + type AudioTrack, + type MaybeResolvedPresentation, + MEDIA_PLAYLIST_METADATA_KEY, + type Presentation, + type SelectionSet, + type VideoSelectionSet, + type VideoTrack, +} from '../../../media/types'; +import { liveWindowFromState } from '../live-window'; + +function videoTrack(start: number): VideoTrack { + return { + 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/v-init.mp4' }, + duration: Number.POSITIVE_INFINITY, + startTime: start, + startDate: 1000, + segments: [0, 2, 4, 6, 8].map((o, i) => ({ id: `v-${i}`, url: `v${i}.m4s`, duration: 2, startTime: start + o })), + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 2, endList: false } }, + }; +} + +function audioTrack(start: number): AudioTrack { + return { + type: 'audio', + id: 'a-1', + groupId: 'audio-hi', + name: 'Default', + sampleRate: 48_000, + channels: 2, + url: 'https://example.com/audio.m3u8', + mimeType: 'audio/mp4', + codecs: ['mp4a.40.2'], + bandwidth: 128_000, + initialization: { url: 'https://example.com/a-init.mp4' }, + duration: Number.POSITIVE_INFINITY, + startTime: start, + startDate: 1000, + segments: [0, 2, 4, 6, 8].map((o, i) => ({ id: `a-${i}`, url: `a${i}.m4s`, duration: 2, startTime: start + o })), + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 2, endList: false } }, + }; +} + +function presentation(opts: { video?: VideoTrack; audio?: AudioTrack }): Presentation { + const selectionSets: SelectionSet[] = []; + if (opts.video) { + const set: VideoSelectionSet = { + id: 'v-set', + type: 'video', + switchingSets: [{ id: 'vss', type: 'video', tracks: [opts.video] }], + }; + selectionSets.push(set); + } + if (opts.audio) { + const set: AudioSelectionSet = { + id: 'a-set', + type: 'audio', + switchingSets: [{ id: 'ass', type: 'audio', tracks: [opts.audio] }], + }; + selectionSets.push(set); + } + return { id: 'pres-1', url: 'https://example.com/master.m3u8', startTime: 0, selectionSets }; +} + +function state(opts: { presentation?: MaybeResolvedPresentation; videoId?: string; audioId?: string }) { + return { + presentation: signal(opts.presentation), + selectedVideoTrackId: signal(opts.videoId), + selectedAudioTrackId: signal(opts.audioId), + }; +} + +describe('liveWindowFromState', () => { + it('uses the selected video track window when video is present (A+V)', () => { + // video window [100,110], audio window [200,210] — video must win. + const pres = presentation({ video: videoTrack(100), audio: audioTrack(200) }); + expect(liveWindowFromState(state({ presentation: pres, videoId: 'v-1', audioId: 'a-1' }))).toEqual({ + start: 100, + end: 110, + targetDuration: 2, + }); + }); + + it('falls back to the selected audio track window for audio-only sources', () => { + // no video track / no video selection — the audio window is authoritative. + const pres = presentation({ audio: audioTrack(200) }); + expect(liveWindowFromState(state({ presentation: pres, videoId: undefined, audioId: 'a-1' }))).toEqual({ + start: 200, + end: 210, + targetDuration: 2, + }); + }); + + it('returns null when no track is selected', () => { + const pres = presentation({ video: videoTrack(100) }); + expect(liveWindowFromState(state({ presentation: pres, videoId: undefined, audioId: undefined }))).toBeNull(); + }); + + it('returns null without a resolved presentation', () => { + expect(liveWindowFromState(state({ presentation: undefined, videoId: 'v-1' }))).toBeNull(); + }); +});