diff --git a/packages/spf/src/media/utils/tests/track-selection.test.ts b/packages/spf/src/media/utils/tests/track-selection.test.ts index 1fd5a26e..7db792b1 100644 --- a/packages/spf/src/media/utils/tests/track-selection.test.ts +++ b/packages/spf/src/media/utils/tests/track-selection.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest'; -import type { AudioTrack, Presentation, VideoTrack } from '../../types'; -import { getResolvedSelectedTrackDuration, type TrackSelectionState } from '../track-selection'; +import { type AudioTrack, MEDIA_PLAYLIST_METADATA_KEY, type Presentation, type VideoTrack } from '../../types'; +import { + getResolvedSelectedTrackDuration, + resolveSelectedTrackDuration, + type TrackSelectionState, +} from '../track-selection'; + +const withCompleteness = (track: T, endList: boolean): T => + ({ + ...track, + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { targetDuration: 10, mediaSequence: 0, endList } }, + }) as T; function createPresentation(config: { video?: VideoTrack[]; audio?: AudioTrack[]; duration?: number }): Presentation { const selectionSets = []; @@ -132,3 +142,40 @@ describe('getResolvedSelectedTrackDuration', () => { expect(getResolvedSelectedTrackDuration({})).toBeUndefined(); }); }); + +describe('resolveSelectedTrackDuration', () => { + it('returns the finite duration for a complete (endList) track', () => { + const state: TrackSelectionState = { + presentation: createPresentation({ video: [withCompleteness(resolvedVideoTrack({ duration: 120.5 }), true)] }), + selectedVideoTrackId: 'video-1', + }; + expect(resolveSelectedTrackDuration(state)).toBe(120.5); + }); + + it('returns Infinity for an incomplete (no endList) track — still growing / live', () => { + const state: TrackSelectionState = { + presentation: createPresentation({ video: [withCompleteness(resolvedVideoTrack({ duration: 30 }), false)] }), + selectedVideoTrackId: 'video-1', + }; + expect(resolveSelectedTrackDuration(state)).toBe(Number.POSITIVE_INFINITY); + }); + + it('falls back to audio completeness when only audio is selected', () => { + const state: TrackSelectionState = { + presentation: createPresentation({ audio: [withCompleteness(resolvedAudioTrack({ duration: 90.25 }), true)] }), + selectedAudioTrackId: 'audio-1', + }; + expect(resolveSelectedTrackDuration(state)).toBe(90.25); + }); + + it('returns undefined when the selected track is not yet resolved', () => { + const state: TrackSelectionState = { + presentation: { + url: 'http://example.com/playlist.m3u8', + selectionSets: [{ type: 'video', switchingSets: [{ tracks: [{ id: 'video-1', type: 'video' }] }] }], + } as any, + selectedVideoTrackId: 'video-1', + }; + expect(resolveSelectedTrackDuration(state)).toBeUndefined(); + }); +}); diff --git a/packages/spf/src/media/utils/track-selection.ts b/packages/spf/src/media/utils/track-selection.ts index 67ba2883..01b14bdc 100644 --- a/packages/spf/src/media/utils/track-selection.ts +++ b/packages/spf/src/media/utils/track-selection.ts @@ -8,7 +8,7 @@ import type { TrackType, VideoTrack, } from '../types'; -import { isResolvedTrack } from '../types'; +import { getMediaPlaylistMetadata, isResolvedTrack } from '../types'; /** * State shape for track selection. @@ -80,3 +80,31 @@ export function getResolvedSelectedTrackDuration(state: TrackSelectionState): nu } return undefined; } + +/** + * Completeness-aware duration resolver — the unified VoD + live default for + * `calculatePresentationDuration`. Like {@link getResolvedSelectedTrackDuration} + * it picks the first resolved selected track (video preferred, audio fallback), + * but branches on the playlist's completeness: a complete playlist + * (`#EXT-X-ENDLIST`) yields its finite `duration`; an incomplete one is still + * growing, so it yields `Infinity` (the MSE live value). `undefined` while no + * selected track is resolved yet. + * + * Keys off completeness, *not* `streamType`: `deriveStreamType` marks any + * playlist lacking `#EXT-X-PLAYLIST-TYPE:VOD` as `'live'`, which would wrongly + * force `Infinity` on a plain VoD stream that only carries `#EXT-X-ENDLIST`. + * See live-presentation-modeling.md (category [2b] completeness). + */ +export function resolveSelectedTrackDuration(state: TrackSelectionState): number | undefined { + const durationByCompleteness = (track: VideoTrack | AudioTrack) => + getMediaPlaylistMetadata(track)?.endList ? track.duration : Number.POSITIVE_INFINITY; + if (state.selectedVideoTrackId) { + const video = getSelectedTrack(state, 'video'); + if (video && isResolvedTrack(video)) return durationByCompleteness(video); + } + if (state.selectedAudioTrackId) { + const audio = getSelectedTrack(state, 'audio'); + if (audio && isResolvedTrack(audio)) return durationByCompleteness(audio); + } + return undefined; +} 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 7e419cad..a70acd9f 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 @@ -74,6 +74,11 @@ function seekToLiveEdgeSetup({ const track = findTrack(presentation, 'video', trackId); if (!track || !isResolvedTrack(track) || track.segments.length === 0) return; + // Complete playlist (VoD, or live that has ended) → no live edge to seek to. + // This is the liveness guard that keeps the behavior inert in the unified + // engine: a VoD source never declares a live seekable range or seeks. + if (getMediaPlaylistMetadata(track)?.endList) return; + const { segments } = track; const windowStart = segments[0]!.startTime; const last = segments[segments.length - 1]!; diff --git a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts index eca1b594..1767520e 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/seek-to-live-edge.test.ts @@ -94,6 +94,23 @@ describe('seekToLiveEdge', () => { cleanup(); }); + it('no-ops for a complete (endList) playlist — VoD / ended live', () => { + const ms = fakeMediaSource(); + const el = { currentTime: 0 } as HTMLMediaElement; + + const presentation = makePresentation(); + // Mark the selected video track's playlist complete. + const video = presentation.selectionSets[0]!.switchingSets[0]!.tracks[0] as VideoTrack; + video.metadata = { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 50, targetDuration: 2, endList: true } }; + + const cleanup = run({ presentation, trackId: 'v-1', mediaElement: el, mediaSource: ms }); + + expect(ms.setLiveSeekableRange).not.toHaveBeenCalled(); + expect(el.currentTime).toBe(0); + + cleanup(); + }); + it('no-ops without a resolved presentation or selected track', () => { const ms = fakeMediaSource(); const el = { currentTime: 0 } as HTMLMediaElement; diff --git a/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts b/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts index 9ffa4f2d..9d66aa02 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts @@ -192,6 +192,31 @@ describe('updateMediaSourceDuration', () => { reactor.destroy(); }); + it('writes Infinity for live only once a mid-append buffer goes idle', async () => { + // Regression: a synchronous Infinity write that races an in-flight append + // throws (MSE forbids setting duration while a buffer is updating) and was + // swallowed, leaving the live stream pinned to a finite duration. The write + // must defer to a non-updating instant. + const { state, context, reactor } = setupUpdateMediaSourceDuration(); + + const { buffer: mockBuffer, finishUpdating } = makeUpdatingSourceBuffer(); + const mockMediaSource = makeMediaSource({ duration: 30, sourceBuffers: [mockBuffer] }); + context.mediaSource.set(mockMediaSource); + state.presentation.set({ duration: Number.POSITIVE_INFINITY } as Presentation); + + // Buffer still updating — must not have written yet (and must not throw). + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mockMediaSource.duration).toBe(30); + + // Append finishes — Infinity is written, overriding the finite value. + finishUpdating(); + await vi.waitFor(() => { + expect(mockMediaSource.duration).toBe(Number.POSITIVE_INFINITY); + }); + + reactor.destroy(); + }); + it('extends duration to match buffered range if needed', async () => { const { state, context, reactor } = setupUpdateMediaSourceDuration(); diff --git a/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts b/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts index 73353b30..eb023a21 100644 --- a/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts +++ b/packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts @@ -4,14 +4,14 @@ * * Two paths, by whether the presentation is live: * - * - **Live** (`presentation.duration === Infinity`): write `Infinity` ahead of - * the first append (no buffered clamp needed; `Infinity` ≥ any range), which - * would otherwise pin `duration` to the buffered (live-edge) end — and once - * the window slides past that, further appends are rejected. Written - * synchronously when the MediaSource is already open; otherwise deferred to - * the next `sourceopen` (the presentation can resolve to `Infinity` before - * `setupMediaSource` opens the MediaSource, so a synchronous-only write would - * miss the window). + * - **Live** (`presentation.duration === Infinity`): write `Infinity` once the + * MediaSource is open and no SourceBuffer is mid-append. No buffered clamp is + * needed (`Infinity` ≥ any range) and — unlike the finite case — it needn't + * precede the first append: `Infinity` overrides whatever finite live-edge + * value an append may have pinned (a finite duration would otherwise stall + * the stream once the window slides past it). The wait-for-idle is required + * because the MSE spec forbids setting `duration` while a buffer is + * `updating`; both waits resolve immediately when already open / idle. * * - **VoD** (finite): the value is written once, after `mediaSource` is open and * all SourceBuffers are idle, clamped to be ≥ the highest buffered range (MSE @@ -107,22 +107,23 @@ function updateMediaSourceDurationSetup({ // continuously, so the buffers are rarely all idle). if (presentation.duration === Number.POSITIVE_INFINITY) { if (mediaSource.duration === Number.POSITIVE_INFINITY) return; - // Open already → write synchronously (fastest, gets ahead of appends). - if (mediaSource.readyState === 'open') { - mediaSource.duration = Number.POSITIVE_INFINITY; - return; - } - // Not open yet → wait for `sourceopen`, then write. The presentation - // can resolve to `Infinity` before `setupMediaSource` opens the - // MediaSource; returning here without waiting (as this once did) - // would miss the write entirely and let the first append pin a - // finite duration. The continuation still runs before the - // network-delayed first append; if an append did land first, - // `Infinity` ≥ its range so the write is still valid. + // Live: write Infinity once the MediaSource is open and no buffer is + // mid-append. Unlike the finite VoD case, Infinity needn't *precede* + // the first append — Infinity ≥ any buffered range, so it overrides + // whatever finite live-edge value an append may have pinned. The + // wait-for-idle is required, not just nice: the MSE spec forbids + // setting `duration` while a SourceBuffer is `updating`, and a + // synchronous write that races an in-flight append throws (and would + // leave the stream pinned to a finite duration, stalling once the + // window slides past it). `waitForMediaSourceOpen` / + // `waitForSourceBuffersReady` resolve immediately when already + // open / idle, so the common case still writes promptly. const controller = new AbortController(); void (async () => { await waitForMediaSourceOpen(mediaSource, controller.signal); if (controller.signal.aborted || mediaSource.readyState !== 'open') return; + await waitForSourceBuffersReady(mediaSource.sourceBuffers, controller.signal); + if (controller.signal.aborted || mediaSource.readyState !== 'open') return; if (mediaSource.duration !== Number.POSITIVE_INFINITY) { mediaSource.duration = Number.POSITIVE_INFINITY; } diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 21147758..51fc5de8 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -18,12 +18,13 @@ import { import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant'; import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation, TextTrack, VideoTrack } from '../../../media/types'; import type { GetCdnId } from '../../../media/utils/cdn'; -import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection'; +import { resolveSelectedTrackDuration } from '../../../media/utils/track-selection'; import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth-estimator'; import type { SegmentLoaderActor } from '../../actors/dom/segment-loader'; import type { SourceBufferActor } from '../../actors/dom/source-buffer'; import type { TextTracksActor } from '../../actors/dom/text-tracks'; import type { TextTrackSegmentLoaderActor, TextTrackSegmentResolver } from '../../actors/text-track-segment-loader'; +import { anchorLiveTracks } from '../../behaviors/anchor-live-tracks'; import { calculatePresentationDuration, type PresentationDurationResolver, @@ -31,6 +32,7 @@ import { import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority'; import { endOfStream } from '../../behaviors/dom/end-of-stream'; import { loadAudioSegments, loadTextTrackSegments, loadVideoSegments } from '../../behaviors/dom/load-segments'; +import { seekToLiveEdge } from '../../behaviors/dom/seek-to-live-edge'; import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors'; import { setupMediaSource } from '../../behaviors/dom/setup-mediasource'; import { setupTextTrackActors } from '../../behaviors/dom/setup-text-track-actors'; @@ -40,6 +42,11 @@ import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers'; import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration'; import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation'; import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../behaviors/resolve-track'; +import { + scheduleAudioTrackReload, + scheduleTextTrackReload, + scheduleVideoTrackReload, +} from '../../behaviors/schedule-track-reload'; import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor'; import { syncPreload } from '../../behaviors/sync-preload'; import { switchAudioTrack, switchTextTrack, switchVideoTrack } from '../../behaviors/track-switching'; @@ -172,14 +179,20 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig; /** - * Resolver for `presentation.duration`. Defaults to picking the first - * resolved selected track's duration (video preferred, audio fallback) — - * appropriate for VoD and audio-only. Live engines should supply a - * resolver that returns `Number.POSITIVE_INFINITY` once the presentation - * is established as live; downstream `updateMediaSourceDuration` propagates - * that value to `mediaSource.duration` per the MSE spec. + * Resolver for `presentation.duration`. Defaults to + * `resolveSelectedTrackDuration`, which handles both VoD and live: the first + * resolved selected track's duration when its playlist is complete + * (`#EXT-X-ENDLIST`), else `Number.POSITIVE_INFINITY` (still growing → live). + * Downstream `updateMediaSourceDuration` propagates the value to + * `mediaSource.duration` per the MSE spec. Override to force a value. */ resolveDuration?: PresentationDurationResolver; + /** + * Sequence number assumed to be the stream origin (time 0) for the live + * timeline anchor (`anchorLiveTracks`). Default 0. Only meaningful for live + * sources; ignored for VoD (the anchor is a no-op without `#EXT-X-PROGRAM-DATE-TIME`). + */ + startSequence?: number; /** * Manifest parser handed to `resolvePresentation`. Defaults to the HLS * multivariant-playlist parser; supply your own for alternate format @@ -301,7 +314,7 @@ export function createSimpleHlsEngine( ...config, canPlayTrack: config.canPlayTrack ?? canPlayTrack, resolveTextTrackSegment: config.resolveTextTrackSegment ?? resolveVttSegment, - resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration, + resolveDuration: config.resolveDuration ?? resolveSelectedTrackDuration, parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist, addSubtitlesTracksToMedia: config.addSubtitlesTracksToMedia ?? addSubtitlesTracksToMedia, getShowingSubtitlesTrackFromMedia: config.getShowingSubtitlesTrackFromMedia ?? getShowingSubtitlesTrackFromMedia, @@ -337,12 +350,24 @@ export function createSimpleHlsEngine( // Resolve selected tracks (fetch media playlists). Composed before the // switch* slot owners; selection is reactive, so a resolve* re-fires once - // its switch* sets the id (same convergence for all three types). + // its switch* sets the id (same convergence for all three types). Also the + // live loader: re-fetches when its reload epoch advances (below). resolveVideoTrack, resolveAudioTrack, resolveTextTrack, - // Presentation duration + // Live refetch policy: bump each type's reload epoch on a target-duration + // cadence until `#EXT-X-ENDLIST`. Inert for VoD (a complete playlist never + // reloads), so these compose unconditionally. + scheduleVideoTrackReload, + scheduleAudioTrackReload, + scheduleTextTrackReload, + + // Re-base selected live tracks' timelines to the estimated stream origin + // (segment.startTime ≈ native PTS). No-op for VoD (no PDT / shift 0). + anchorLiveTracks, + + // Presentation duration (finite for complete playlists, Infinity for live) calculatePresentationDuration, // MSE setup. Video cluster is registered first so that, when both @@ -373,6 +398,10 @@ export function createSimpleHlsEngine( loadVideoSegments, loadAudioSegments, + // Live: declare the seekable window and seek the playhead to the live + // edge once segments land. No-op for complete playlists (VoD / ended). + seekToLiveEdge, + // End of stream coordination endOfStream,