diff --git a/packages/html/src/media/simple-hls-video/index.ts b/packages/html/src/media/simple-hls-video/index.ts index 6fbb498d..8ca00387 100644 --- a/packages/html/src/media/simple-hls-video/index.ts +++ b/packages/html/src/media/simple-hls-video/index.ts @@ -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'; + } } } diff --git a/packages/react/src/media/simple-hls-video/index.tsx b/packages/react/src/media/simple-hls-video/index.tsx index 27e7d71c..fb5e9e71 100644 --- a/packages/react/src/media/simple-hls-video/index.tsx +++ b/packages/react/src/media/simple-hls-video/index.tsx @@ -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>; export const SimpleHlsVideo = forwardRef(({ children, ...props }, ref) => { diff --git a/packages/spf/src/core/features/resolve-presentation.ts b/packages/spf/src/core/features/resolve-presentation.ts index 1d564846..06e32708 100644 --- a/packages/spf/src/core/features/resolve-presentation.ts +++ b/packages/spf/src/core/features/resolve-presentation.ts @@ -77,6 +77,9 @@ export function syncPreloadAttribute( owners: WritableState ): () => 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 }); }); diff --git a/packages/spf/src/core/features/tests/resolve-presentation.test.ts b/packages/spf/src/core/features/tests/resolve-presentation.test.ts index 98e561c1..ef7a7c1e 100644 --- a/packages/spf/src/core/features/tests/resolve-presentation.test.ts +++ b/packages/spf/src/core/features/tests/resolve-presentation.test.ts @@ -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(); }); diff --git a/packages/spf/src/dom/playback-engine/adapter.ts b/packages/spf/src/dom/playback-engine/adapter.ts index b6617f26..cc893bcd 100644 --- a/packages/spf/src/dom/playback-engine/adapter.ts +++ b/packages/spf/src/dom/playback-engine/adapter.ts @@ -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 }); } 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 32c5162a..96250ec9 100644 --- a/packages/spf/src/dom/playback-engine/tests/adapter.test.ts +++ b/packages/spf/src/dom/playback-engine/tests/adapter.test.ts @@ -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) // ---------------------------------------------------------------------------