diff --git a/packages/spf/src/playback/behaviors/dom/end-of-stream.ts b/packages/spf/src/playback/behaviors/dom/end-of-stream.ts index 35e99ded..47e6806b 100644 --- a/packages/spf/src/playback/behaviors/dom/end-of-stream.ts +++ b/packages/spf/src/playback/behaviors/dom/end-of-stream.ts @@ -1,9 +1,11 @@ /** * **Drive each `open → ended` transition of the MediaSource.** Calls - * `MediaSource.endOfStream()` once the temporally last segments of every - * active buffer actor's currently-loading track are fully appended and - * the user has reached them — letting the browser finalize duration and - * fire `ended` on the media element. + * `MediaSource.endOfStream()` once every active buffer actor's + * currently-loading track is *complete* (`#EXT-X-ENDLIST`), its temporally + * last segments are fully appended, and the user has reached them — letting + * the browser finalize duration and fire `ended` on the media element. The + * completeness gate keeps it inert for ongoing live, whose "last segment" is + * only the rolling edge; a live stream opts in by appending `#EXT-X-ENDLIST`. * * Re-fires on every subsequent `open → ended → open` cycle. Per the MSE * spec, `appendBuffer()` after `endOfStream()` transitions the MediaSource @@ -99,7 +101,7 @@ import { getMaxBufferedEnd, waitForSourceBuffersReady } from '../../../media/dom import { isLastSegmentAppended } from '../../../media/dom/mse/end-of-stream'; import { onMediaSourceReadyStateChange } from '../../../media/dom/mse/mediasource-setup'; import type { MaybeResolvedPresentation } from '../../../media/types'; -import { isResolvedTrack } from '../../../media/types'; +import { getMediaPlaylistMetadata, isResolvedTrack } from '../../../media/types'; import { findTrackById } from '../../../media/utils/tracks'; import type { SourceBufferActor } from '../../actors/dom/source-buffer'; @@ -144,6 +146,13 @@ function deriveState( const track = findTrackById(presentation, initTrackId); if (!track || !isResolvedTrack(track)) return 'preconditions-unmet'; + // Only a complete playlist (#EXT-X-ENDLIST) has a true last segment. For an + // ongoing live playlist the last segment is just the current rolling edge, + // so end-of-stream must not fire — calling `endOfStream()` there would pin a + // finite (live-edge) duration and end the stream, only to be reopened by the + // next reload's appends and re-fire on a loop. When a live stream genuinely + // ends it appends #EXT-X-ENDLIST, which opens this guard. + if (!getMediaPlaylistMetadata(track)?.endList) return 'preconditions-unmet'; if (!isLastSegmentAppended(track.segments, appended)) return 'preconditions-unmet'; if (track.segments.length > 0) { diff --git a/packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts b/packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts index de47523a..2bf2ffcd 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; import type { ContextSignals, StateSignals } from '../../../../core/composition/create-composition'; import { signal } from '../../../../core/signals/primitives'; -import type { MaybeResolvedPresentation, Presentation, Segment, VideoTrack } from '../../../../media/types'; +import { + type MaybeResolvedPresentation, + MEDIA_PLAYLIST_METADATA_KEY, + type Presentation, + type Segment, + type VideoTrack, +} from '../../../../media/types'; import { createSourceBufferActor, type SourceBufferActor } from '../../../actors/dom/source-buffer'; import { type EndOfStreamContext, type EndOfStreamState, endOfStream } from '../end-of-stream'; @@ -110,7 +116,7 @@ function makeSegments(count: number): Segment[] { })); } -function makeResolvedVideoTrack(segmentCount: number, id = 'video-1'): VideoTrack { +function makeResolvedVideoTrack(segmentCount: number, id = 'video-1', endList = true): VideoTrack { return { id, type: 'video' as const, @@ -122,6 +128,7 @@ function makeResolvedVideoTrack(segmentCount: number, id = 'video-1'): VideoTrac startTime: 0, duration: segmentCount * 2.5, codecs: 'avc1.64001f', + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 3, endList } }, } as unknown as VideoTrack; } @@ -309,6 +316,48 @@ describe('endOfStream', () => { await cleanup(); }); + it('does not call endOfStream() for a live (incomplete) playlist even with the last segment appended', async () => { + // Live: no #EXT-X-ENDLIST. The "last segment" is only the rolling edge, so + // appending it must NOT end the stream — doing so would pin a finite + // (live-edge) duration and reopen/re-fire on every reload. + const track = makeResolvedVideoTrack(2, 'video-1', false); + const mockMs = makeMediaSource(); + const actor = makeActorWithSegments(['seg-0', 'seg-1']); + + const { cleanup } = setupEndOfStream( + { presentation: makePresentation(track) }, + { mediaSource: mockMs, videoBufferActor: actor } + ); + + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(mockMs.endOfStream).not.toHaveBeenCalled(); + await cleanup(); + }); + + it('calls endOfStream() once a live playlist appends #EXT-X-ENDLIST (graceful end)', async () => { + // A live stream that ends appends #EXT-X-ENDLIST; the guard then opens and + // end-of-stream fires normally. + const liveTrack = makeResolvedVideoTrack(2, 'video-1', false); + const mockMs = makeMediaSource(); + const actor = makeActorWithSegments(['seg-0', 'seg-1']); + + const { state, cleanup } = setupEndOfStream( + { presentation: makePresentation(liveTrack) }, + { mediaSource: mockMs, videoBufferActor: actor } + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mockMs.endOfStream).not.toHaveBeenCalled(); + + // Stream ends: a reload patches in the same window now marked complete. + state.presentation.set(makePresentation(makeResolvedVideoTrack(2, 'video-1', true))); + + await vi.waitFor(() => { + expect(mockMs.endOfStream).toHaveBeenCalledTimes(1); + }); + await cleanup(); + }); + it('calls endOfStream() only once while MediaSource stays ended', async () => { const track = makeResolvedVideoTrack(4); const mockMs = makeMediaSource(); @@ -409,6 +458,7 @@ describe('endOfStream', () => { url: 'https://example.com/audio.m3u8', mimeType: 'audio/mp4', segments: makeSegments(4).map((s) => ({ ...s, id: `audio-${s.id}` })), + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 3, endList: true } }, } as unknown as VideoTrack; const presentation = { id: 'pres-1',