fix(spf): derive the live window from any resolved track during selection churn

liveWindowFromState keyed the live window on the *selected* timeline-bearing
track, so when an ABR/user switch briefly left the newly-selected rendition
unresolved (a shell, no segments), the window blinked to null. That flipped
seekToLiveEdge out of `live` and stalled the seekable-range writer. The live
window is a presentation-level property — all renditions are time-aligned and
share the anchor — so fall back to any resolved track of the timeline-bearing
type when the selected one isn't resolved yet, rather than returning null. A
deselected track's window may trail live by up to one reload during the switch
gap; the selected track's fresh window resumes the moment it resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:47 -07:00
co-authored by Claude Opus 4.8
parent 7c99f266a6
commit 4572730e0b
2 changed files with 47 additions and 1 deletions
@@ -14,6 +14,7 @@
import type { ReadonlySignal } from '../../core/signals/primitives';
import { type LiveWindow, liveWindowFor } from '../../media/live-window';
import type { MaybeResolvedPresentation } from '../../media/types';
import { getTracksByType } from '../../media/utils/tracks';
export interface LiveWindowState {
presentation: ReadonlySignal<MaybeResolvedPresentation | undefined>;
@@ -31,7 +32,28 @@ export function liveTrackId(state: LiveWindowState): string | undefined {
}
export function liveWindowFromState(state: LiveWindowState): LiveWindow | null {
return liveWindowFor(state.presentation.get(), liveTrackId(state));
const presentation = state.presentation.get();
// Prefer the selected timeline-bearing track when it's resolved.
const selected = liveWindowFor(presentation, liveTrackId(state));
if (selected) return selected;
// The selected track isn't resolved yet (e.g. briefly mid ABR / user switch).
// The live window is a presentation-level property — all renditions are
// time-aligned and share the anchor — so fall back to any resolved track of the
// timeline-bearing type rather than letting the window blink to `null`. A null
// blink would flip `seekToLiveEdge` out of `live` and re-fire its one-time edge
// seek (yanking a viewer who has scrubbed back), and stall the seekable-range
// writer. A deselected track's window may trail live by up to one reload during
// the switch gap — acceptable, and the selected track's fresh window resumes the
// moment it resolves.
if (!presentation) return null;
const type = state.selectedVideoTrackId?.get() ? 'video' : 'audio';
for (const track of getTracksByType(presentation, type)) {
const window = liveWindowFor(presentation, track.id);
if (window) return window;
}
return null;
}
/**
@@ -5,6 +5,7 @@ import {
type AudioTrack,
type MaybeResolvedPresentation,
MEDIA_PLAYLIST_METADATA_KEY,
type PartiallyResolvedVideoTrack,
type Presentation,
type SelectionSet,
type VideoSelectionSet,
@@ -98,6 +99,29 @@ describe('liveWindowFromState', () => {
});
});
it('falls back to a resolved track when the selected track is not yet resolved (mid-switch)', () => {
// ABR/user switch in flight: the selected rendition (v-2) is still a shell.
// The window is presentation-level, so it must come from the resolved sibling
// (v-1) rather than blinking to null.
const shell: PartiallyResolvedVideoTrack = {
type: 'video',
id: 'v-2',
url: 'https://example.com/v2.m3u8',
mimeType: 'video/mp4',
codecs: ['avc1.640028'],
bandwidth: 2_000_000,
};
const pres: Presentation = {
id: 'pres-1',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [
{ id: 'v-set', type: 'video', switchingSets: [{ id: 'vss', type: 'video', tracks: [videoTrack(100), shell] }] },
],
};
expect(liveWindowFromState(state({ presentation: pres, videoId: 'v-2' }))).toEqual({ start: 100, end: 110 });
});
it('returns null when no track is selected', () => {
const pres = presentation({ video: videoTrack(100) });
expect(liveWindowFromState(state({ presentation: pres, videoId: undefined, audioId: undefined }))).toBeNull();