fix(spf): derive the live window for audio-only sources

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) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:21 -07:00
co-authored by Claude Opus 4.8
parent 02af12e9d9
commit 0a7187eb22
6 changed files with 193 additions and 19 deletions
+10 -10
View File
@@ -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`
@@ -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<VideoTrack>): 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 });
});
});
@@ -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<SeekToLiveEdgeState['presentation']>;
selectedVideoTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedVideoTrackId']>;
selectedAudioTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedAudioTrackId']>;
};
context: {
mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;
@@ -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.
@@ -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<SyncLiveSeekableRangeState['presentation']>;
selectedVideoTrackId?: ReadonlySignal<SyncLiveSeekableRangeState['selectedVideoTrackId']>;
selectedAudioTrackId?: ReadonlySignal<SyncLiveSeekableRangeState['selectedAudioTrackId']>;
};
context: {
mediaSource: ReadonlySignal<SyncLiveSeekableRangeContext['mediaSource']>;
@@ -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
@@ -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<MaybeResolvedPresentation | undefined>;
selectedVideoTrackId?: ReadonlySignal<string | undefined>;
selectedAudioTrackId?: ReadonlySignal<string | undefined>;
}
export function liveWindowFromState(state: LiveWindowState): LiveWindow | null {
const trackId = state.selectedVideoTrackId?.get() ?? state.selectedAudioTrackId?.get();
return liveWindowFor(state.presentation.get(), trackId);
}
@@ -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<MaybeResolvedPresentation | undefined>(opts.presentation),
selectedVideoTrackId: signal<string | undefined>(opts.videoId),
selectedAudioTrackId: signal<string | undefined>(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();
});
});