mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(spf): fix async teardown leaks and recreate engine on src change (#841)
This commit is contained in:
@@ -254,9 +254,11 @@ export function endOfStream({
|
||||
owners: WritableState<EndOfStreamOwners>;
|
||||
}): () => 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();
|
||||
|
||||
@@ -275,5 +275,8 @@ export function loadTextTrackCues({
|
||||
}
|
||||
);
|
||||
|
||||
return cleanup;
|
||||
return () => {
|
||||
abortController?.abort();
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,13 +52,15 @@ export function setupMediaSource({
|
||||
owners: WritableState<MediaSourceOwners>;
|
||||
}): () => 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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ export function updateDuration({
|
||||
state: WritableState<DurationUpdateState>;
|
||||
owners: WritableState<DurationUpdateOwners>;
|
||||
}): () => 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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<void>((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');
|
||||
|
||||
Reference in New Issue
Block a user