diff --git a/packages/spf/src/dom/features/end-of-stream.ts b/packages/spf/src/dom/features/end-of-stream.ts index a0adc5d8..f066010e 100644 --- a/packages/spf/src/dom/features/end-of-stream.ts +++ b/packages/spf/src/dom/features/end-of-stream.ts @@ -254,9 +254,11 @@ export function endOfStream({ owners: WritableState; }): () => void { let hasEnded = false; + let destroyed = false; const activeActorUnsubs: Array<() => void> = []; const runEvaluate = async () => { + if (destroyed) return; const currentState = state.current; const currentOwners = owners.current; if (hasEnded) { @@ -306,6 +308,7 @@ export function endOfStream({ const cleanupCombineLatest = combineLatest([state, owners]).subscribe(async () => runEvaluate()); return () => { + destroyed = true; activeActorUnsubs.forEach((u) => u()); cleanupOwners(); cleanupCombineLatest(); diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 6aac326a..f93bb4e7 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -275,5 +275,8 @@ export function loadTextTrackCues({ } ); - return cleanup; + return () => { + abortController?.abort(); + cleanup(); + }; } diff --git a/packages/spf/src/dom/features/setup-mediasource.ts b/packages/spf/src/dom/features/setup-mediasource.ts index 19549d58..45b1c5d7 100644 --- a/packages/spf/src/dom/features/setup-mediasource.ts +++ b/packages/spf/src/dom/features/setup-mediasource.ts @@ -52,13 +52,15 @@ export function setupMediaSource({ owners: WritableState; }): () => void { let settingUp = false; + let abortController: AbortController | null = null; - return combineLatest([state, owners]).subscribe( + const unsubscribe = combineLatest([state, owners]).subscribe( async ([currentState, currentOwners]: [MediaSourceState, MediaSourceOwners]) => { if (!canSetup(currentState, currentOwners) || !shouldSetup(currentState, currentOwners) || settingUp) return; try { settingUp = true; + abortController = new AbortController(); // Create MediaSource // Note: ManagedMediaSource (Safari) requires disableRemotePlayback and @@ -69,14 +71,21 @@ export function setupMediaSource({ // Attach to element attachMediaSource(mediaSource, currentOwners.mediaElement!); - // Wait for sourceopen - await waitForSourceOpen(mediaSource); + // Wait for sourceopen — aborted if destroy() is called before it fires + await waitForSourceOpen(mediaSource, abortController.signal); - // Update owners with created MediaSource owners.patch({ mediaSource }); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + throw error; } finally { settingUp = false; } } ); + + return () => { + abortController?.abort(); + unsubscribe(); + }; } diff --git a/packages/spf/src/dom/features/update-duration.ts b/packages/spf/src/dom/features/update-duration.ts index 4f7728ba..6ea16675 100644 --- a/packages/spf/src/dom/features/update-duration.ts +++ b/packages/spf/src/dom/features/update-duration.ts @@ -98,7 +98,9 @@ export function updateDuration({ state: WritableState; owners: WritableState; }): () => void { - return combineLatest([state, owners]).subscribe( + let destroyed = false; + + const unsubscribe = combineLatest([state, owners]).subscribe( async ([currentState, currentOwners]: [DurationUpdateState, DurationUpdateOwners]) => { if (!shouldUpdateDuration(currentState, currentOwners)) return; @@ -107,10 +109,9 @@ export function updateDuration({ // MSE spec: duration cannot be set while any SourceBuffer is updating await waitForSourceBuffersReady(currentOwners); - // Re-check readyState after the async wait — the endOfStream orchestrator may - // have called mediaSource.endOfStream() concurrently, transitioning readyState - // to 'ended'. Setting duration in that state throws InvalidStateError. - if (mediaSource!.readyState !== 'open') return; + // Re-check after async wait: destroyed, or readyState changed (e.g. endOfStream + // already called endOfStream(), transitioning 'open' → 'ended'). + if (destroyed || mediaSource!.readyState !== 'open') return; let duration = currentState.presentation!.duration!; @@ -127,4 +128,9 @@ export function updateDuration({ mediaSource!.duration = duration; } ); + + return () => { + destroyed = true; + unsubscribe(); + }; } diff --git a/packages/spf/src/dom/playback-engine/adapter.ts b/packages/spf/src/dom/playback-engine/adapter.ts index b08d2078..b6617f26 100644 --- a/packages/spf/src/dom/playback-engine/adapter.ts +++ b/packages/spf/src/dom/playback-engine/adapter.ts @@ -7,28 +7,36 @@ import { createPlaybackEngine, type PlaybackEngine } from './engine'; * Implements the src/play() contract per the WHATWG HTML spec so that SPF can * be used anywhere a media element API is expected. * - * The engine is created once at construction and reused across src changes and - * mediaElement swaps — attach/detach only update the owner reference. + * A new engine is created on every src assignment — this fully tears down all + * state, SourceBuffers, and in-flight requests from the previous source before + * the next one begins. The media element reference is preserved across src + * changes and re-applied to the new engine automatically. * * @example * const media = new SpfMedia({ preferredAudioLanguage: 'en' }); * media.attach(document.querySelector('video')); * media.src = 'https://stream.mux.com/abc123.m3u8'; * - * // Later, swap element without recreating the engine: - * media.attach(otherVideoElement); + * // Change source — old engine is destroyed, new one starts clean: + * media.src = 'https://stream.mux.com/xyz456.m3u8'; * * // Explicit teardown: * media.destroy(); */ export class SpfMedia { - readonly engine: PlaybackEngine; + #engine: PlaybackEngine; + #config: PlaybackEngineConfig; /** Pending loadstart listener from a deferred play() retry, if any. */ #loadstartListener: (() => void) | null = null; constructor(config: PlaybackEngineConfig = {}) { - this.engine = createPlaybackEngine(config); + this.#config = config; + this.#engine = createPlaybackEngine(config); + } + + get engine(): PlaybackEngine { + return this.#engine; } // --------------------------------------------------------------------------- @@ -36,33 +44,43 @@ export class SpfMedia { // --------------------------------------------------------------------------- attach(mediaElement: HTMLMediaElement): void { - this.engine.owners.patch({ mediaElement }); + this.#engine.owners.patch({ mediaElement }); } detach(): void { this.#cancelPendingPlay(); - this.engine.owners.patch({ mediaElement: undefined }); + this.#engine.owners.patch({ mediaElement: undefined }); } destroy(): void { this.#cancelPendingPlay(); - this.engine.destroy(); + this.#engine.destroy(); } // --------------------------------------------------------------------------- // src — synchronous IDL attribute (WHATWG §4.8.11.2) - // Setting src synchronously triggers the load algorithm via state patch. - // The URL is derived from state — no separate field needed. + // Each assignment destroys the current engine and starts a fresh one, exactly + // as the browser's load algorithm resets all media element state on src change. // --------------------------------------------------------------------------- get src(): string { - return this.engine.state.current.presentation?.url ?? ''; + return this.#engine.state.current.presentation?.url ?? ''; } set src(value: string) { - this.engine.state.patch({ - presentation: value ? { url: value } : undefined, - }); + const prevMediaElement = this.#engine.owners.current.mediaElement; + + this.#cancelPendingPlay(); + this.#engine.destroy(); + this.#engine = createPlaybackEngine(this.#config); + + if (prevMediaElement) { + this.#engine.owners.patch({ mediaElement: prevMediaElement }); + } + + if (value) { + this.#engine.state.patch({ presentation: { url: value } }); + } } // --------------------------------------------------------------------------- @@ -71,13 +89,13 @@ export class SpfMedia { // --------------------------------------------------------------------------- play(): Promise { - const { mediaElement } = this.engine.owners.current; + const { mediaElement } = this.#engine.owners.current; if (!mediaElement) { return Promise.reject(new Error('SpfMedia: no media element attached')); } // Signal play intent — enables loading even with preload="none" - this.engine.state.patch({ playbackInitiated: true }); + this.#engine.state.patch({ playbackInitiated: true }); return mediaElement.play().catch((err: unknown) => { // If we have a pending HLS source, the rejection may be because MSE @@ -103,7 +121,7 @@ export class SpfMedia { #cancelPendingPlay(): void { if (!this.#loadstartListener) return; - const { mediaElement } = this.engine.owners.current; + const { mediaElement } = this.#engine.owners.current; mediaElement?.removeEventListener('loadstart', this.#loadstartListener); this.#loadstartListener = null; } diff --git a/packages/spf/src/dom/playback-engine/tests/adapter.test.ts b/packages/spf/src/dom/playback-engine/tests/adapter.test.ts index 03bb1ffb..32c5162a 100644 --- a/packages/spf/src/dom/playback-engine/tests/adapter.test.ts +++ b/packages/spf/src/dom/playback-engine/tests/adapter.test.ts @@ -98,6 +98,43 @@ describe('SpfMedia', () => { expect(media.engine).toBe(engine); }); + it('creates a new engine when src is set', () => { + const media = new SpfMedia(); + const initial = media.engine; + media.src = 'https://example.com/v1.m3u8'; + expect(media.engine).not.toBe(initial); + }); + + it('destroys the old engine when src changes', () => { + const media = new SpfMedia(); + media.src = 'https://example.com/v1.m3u8'; + const spy = vi.spyOn(media.engine, 'destroy'); + media.src = 'https://example.com/v2.m3u8'; + expect(spy).toHaveBeenCalledOnce(); + }); + + it('re-attaches the media element to the new engine when src changes', () => { + const media = new SpfMedia(); + const el = document.createElement('video'); + media.attach(el); + media.src = 'https://example.com/v1.m3u8'; + expect(media.engine.owners.current.mediaElement).toBe(el); + }); + + it('cancels pending play listener when src changes', async () => { + const media = new SpfMedia(); + const el = document.createElement('video'); + media.attach(el); + media.src = 'https://example.com/v1.m3u8'; + el.play = () => Promise.reject(new Error('no supported sources')); + media.play().catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const spy = vi.spyOn(el, 'removeEventListener'); + media.src = 'https://example.com/v2.m3u8'; + expect(spy).toHaveBeenCalledWith('loadstart', expect.any(Function)); + }); + it('sets mediaElement in owners when attached', () => { const media = new SpfMedia(); const el = document.createElement('video');