fix(spf): implement preload IDL attribute on SpfMedia (#1069)

This commit is contained in:
Christian Pillsbury
2026-03-23 07:30:20 -07:00
committed by GitHub
parent 184da9dcc3
commit 04f81a26e1
6 changed files with 92 additions and 11 deletions
@@ -21,5 +21,9 @@ export class SimpleHlsVideo extends MediaAttachMixin(SimpleHlsCustomMedia) {
if (attrName === 'src' && oldValue !== newValue) {
this.src = newValue ?? '';
}
if (attrName === 'preload' && oldValue !== newValue) {
this.preload = (newValue ?? '') as '' | 'none' | 'metadata' | 'auto';
}
}
}
@@ -6,8 +6,6 @@ import { attachMediaElement } from '../../utils/attach-media-element';
import { mediaProps } from '../../utils/media-props';
import { useComposedRefs } from '../../utils/use-composed-refs';
// TODO: mediaProps uses media.preload which SimpleHlsMedia does not yet expose.
// Add preload getter/setter to SimpleHlsMedia as a follow-up.
export type SimpleHlsVideoProps = PropsWithChildren<VideoHTMLAttributes<HTMLVideoElement>>;
export const SimpleHlsVideo = forwardRef<HTMLVideoElement, SimpleHlsVideoProps>(({ children, ...props }, ref) => {
@@ -77,6 +77,9 @@ export function syncPreloadAttribute(
owners: WritableState<PlatformOwners>
): () => void {
return owners.subscribe((current) => {
// Only infer preload from the element when no explicit value has been set.
// An explicit value (set via SpfMedia.preload) always wins.
if (state.current.preload !== undefined) return;
const preload = current.mediaElement?.preload || undefined;
state.patch({ preload: preload as 'auto' | 'metadata' | 'none' | undefined });
});
@@ -492,7 +492,7 @@ variant1.m3u8`)
cleanup();
});
it('updates preload when mediaElement preload changes', () => {
it('does not override preload when mediaElement changes and preload is already set', () => {
interface State {
presentation?: AddressableObject | Presentation | undefined;
preload?: 'auto' | 'metadata' | 'none' | undefined;
@@ -512,23 +512,24 @@ variant1.m3u8`)
mediaElement: video,
});
// Start syncing
// Start syncing — initial inference from element
const cleanup = syncPreloadAttribute(state, owners);
expect(state.current.preload).toBe('auto');
// Change to different mediaElement with different preload
// Swap to a different mediaElement with a different preload value.
// Since preload is already set, the new element's value is ignored.
const updatedVideo = { preload: 'metadata' } as PlatformOwners['mediaElement'];
owners.patch({ mediaElement: updatedVideo });
owners.flush(); // Flush owners to trigger subscription
state.flush(); // Flush state to apply preload update
owners.flush();
state.flush();
expect(state.current.preload).toBe('metadata');
expect(state.current.preload).toBe('auto');
cleanup();
});
it('sets preload to undefined when mediaElement is removed', () => {
it('does not clear preload when mediaElement is removed and preload is already set', () => {
interface State {
presentation?: AddressableObject | Presentation | undefined;
preload?: 'auto' | 'metadata' | 'none' | undefined;
@@ -549,10 +550,10 @@ variant1.m3u8`)
const cleanup = syncPreloadAttribute(state, owners);
// Remove media element
owners.patch({ mediaElement: undefined });
expect(state.current.preload).toBeUndefined();
// Preload was already set — removing the element does not clear it.
expect(state.current.preload).toBe('auto');
cleanup();
});
@@ -26,6 +26,7 @@ import { createPlaybackEngine, type PlaybackEngine } from './engine';
export class SpfMedia {
#engine: PlaybackEngine;
#config: PlaybackEngineConfig;
#preload: '' | 'none' | 'metadata' | 'auto' = '';
/** Pending loadstart listener from a deferred play() retry, if any. */
#loadstartListener: (() => void) | null = null;
@@ -57,6 +58,24 @@ export class SpfMedia {
this.#engine.destroy();
}
// ---------------------------------------------------------------------------
// preload — synchronous IDL attribute (WHATWG §4.8.11.2)
// ---------------------------------------------------------------------------
get preload(): '' | 'none' | 'metadata' | 'auto' {
return this.#preload;
}
set preload(value: '' | 'none' | 'metadata' | 'auto') {
this.#preload = value;
if (value) {
this.#engine.state.patch({ preload: value });
}
// value = '' clears #preload (so the next engine recreation won't re-apply
// an explicit value) but does not patch current state — the existing preload
// stays in effect until the next src change creates a fresh engine.
}
// ---------------------------------------------------------------------------
// src — synchronous IDL attribute (WHATWG §4.8.11.2)
// Each assignment destroys the current engine and starts a fresh one, exactly
@@ -74,6 +93,12 @@ export class SpfMedia {
this.#engine.destroy();
this.#engine = createPlaybackEngine(this.#config);
// Apply explicit preload before owners.patch so syncPreloadAttribute skips
// element inference and the explicit value is preserved across src changes.
if (this.#preload) {
this.#engine.state.patch({ preload: this.#preload });
}
if (prevMediaElement) {
this.#engine.owners.patch({ mediaElement: prevMediaElement });
}
@@ -284,6 +284,56 @@ describe('SpfMedia', () => {
// Expected: play() resolves after the media element fires 'playing'.
});
// ---------------------------------------------------------------------------
// preload — synchronous IDL attribute (WHATWG §4.8.11.2)
// ---------------------------------------------------------------------------
describe('preload', () => {
it('returns empty string before any preload is set', () => {
const media = new SpfMedia();
expect(media.preload).toBe('');
});
it('reflects the set value synchronously', () => {
const media = new SpfMedia();
media.preload = 'auto';
expect(media.preload).toBe('auto');
});
it('updates engine state immediately when set', () => {
const media = new SpfMedia();
media.preload = 'none';
expect(media.engine.state.current.preload).toBe('none');
});
it('setting preload to empty string resets the stored value but does not clear current engine state', () => {
const media = new SpfMedia();
media.preload = 'auto';
media.preload = '';
// '' only clears #preload so the next engine recreation won't re-apply
// an explicit value — it does not patch the current engine state.
expect(media.engine.state.current.preload).toBe('auto');
});
it('survives src reassignment — explicit preload is preserved across engine recreation', () => {
const media = new SpfMedia();
media.preload = 'none';
media.src = 'https://example.com/v.m3u8';
expect(media.preload).toBe('none');
expect(media.engine.state.current.preload).toBe('none');
});
it('explicit preload is re-applied before owners.patch on src change so syncPreloadAttribute skips inference', () => {
const media = new SpfMedia();
const el = document.createElement('video');
media.attach(el);
media.preload = 'none';
media.src = 'https://example.com/v.m3u8';
// syncPreloadAttribute fires when owners.patch re-attaches the element,
// but since preload was already patched into the new engine's state, it skips.
expect(media.engine.state.current.preload).toBe('none');
});
});
// ---------------------------------------------------------------------------
// destroy() — explicit teardown (separate from detach)
// ---------------------------------------------------------------------------