diff --git a/packages/spf/src/playback/primitives/live-window.ts b/packages/spf/src/playback/primitives/live-window.ts index 0922abf9..f1a40a07 100644 --- a/packages/spf/src/playback/primitives/live-window.ts +++ b/packages/spf/src/playback/primitives/live-window.ts @@ -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; @@ -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; } /** diff --git a/packages/spf/src/playback/primitives/tests/live-window.test.ts b/packages/spf/src/playback/primitives/tests/live-window.test.ts index 18fc6a6d..546d5bfd 100644 --- a/packages/spf/src/playback/primitives/tests/live-window.test.ts +++ b/packages/spf/src/playback/primitives/tests/live-window.test.ts @@ -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();