mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.8
parent
02af12e9d9
commit
0a7187eb22
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user