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)
// ---------------------------------------------------------------------------