Skip to content

Commit 78f7f3e

Browse files
committed
docs(site): rework autoplay snippets per framework idioms
The previous React snippet was a component that called play() in a useEffect — which throws NO_TARGET synchronously, because the provider's store.attach() runs after child effects. Verified via test. React now exposes a useAutoplay() hook that subscribes to the store and waits for store.target before attempting play. Returns the status directly so consumers conditionally render their fallback UI. HTML drops the custom-element wrapper entirely and uses an inline <script type="module"> that reads the store off the <video-player> instance. The subscribe-then-retry pattern handles the case where media hasn't yet attached when the script first runs. Exposes status via a data-autoplay attribute so CSS can drive the fallback. Both verified with vitest behavior tests (playing / muted / blocked status outcomes) using stubbed HTMLMediaElement.prototype.play. https://claude.ai/code/session_01QLEz6mPy8579e4QXq3h1L8
1 parent a4fdd1e commit 78f7f3e

1 file changed

Lines changed: 77 additions & 80 deletions

File tree

site/src/content/docs/how-to/autoplay.mdx

Lines changed: 77 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -83,111 +83,108 @@ video.play().catch(() => {
8383
In v10, route the same logic through the player store so it works for whatever media element you're using — a plain `<video>`, an HLS adapter, or any custom media element.
8484

8585
<FrameworkCase frameworks={["react"]}>
86-
<DocsLink slug="reference/use-player">`usePlayer`</DocsLink> with <DocsLink slug="reference/feature-playback">`selectPlayback`</DocsLink> gives you a `play()` action that returns the same promise, and <DocsLink slug="reference/feature-volume">`selectVolume`</DocsLink> lets you toggle mute on rejection:
86+
<DocsLink slug="reference/use-player">`usePlayer`</DocsLink> with <DocsLink slug="reference/feature-playback">`selectPlayback`</DocsLink> gives you a `play()` action that returns the same promise, and <DocsLink slug="reference/feature-volume">`selectVolume`</DocsLink> lets you toggle mute on rejection. Wrap that up as a hook:
8787

88-
```tsx title="AutoplayHandler.tsx"
88+
```tsx title="useAutoplay.ts"
8989
import { selectPlayback, selectVolume, usePlayer } from '@videojs/react';
90-
import { useEffect, useRef } from 'react';
90+
import { useEffect, useRef, useState } from 'react';
9191

9292
export type AutoplayStatus = 'playing' | 'muted' | 'blocked';
9393

94-
export function AutoplayHandler({ onStatus }: { onStatus?: (status: AutoplayStatus) => void }) {
95-
const playback = usePlayer(selectPlayback);
96-
const volume = usePlayer(selectVolume);
94+
export function useAutoplay(): AutoplayStatus | undefined {
95+
const store = usePlayer();
96+
const [status, setStatus] = useState<AutoplayStatus>();
9797
const attempted = useRef(false);
9898

9999
useEffect(() => {
100-
if (!playback || attempted.current) return;
101-
attempted.current = true;
102-
103-
playback.play().then(
104-
() => onStatus?.('playing'),
105-
() => {
106-
if (volume && !volume.muted) volume.toggleMuted();
107-
playback.play().then(
108-
() => onStatus?.('muted'),
109-
() => onStatus?.('blocked'),
110-
);
111-
},
112-
);
113-
}, [playback, volume, onStatus]);
100+
const tryNow = () => {
101+
if (attempted.current || !store.target) return;
102+
attempted.current = true;
103+
const playback = selectPlayback(store.state);
104+
if (!playback) return;
105+
106+
playback.play().then(
107+
() => setStatus('playing'),
108+
() => {
109+
const volume = selectVolume(store.state);
110+
if (volume && !volume.muted) volume.toggleMuted();
111+
playback.play().then(
112+
() => setStatus('muted'),
113+
() => setStatus('blocked'),
114+
);
115+
},
116+
);
117+
};
118+
tryNow();
119+
return store.subscribe(tryNow);
120+
}, [store]);
114121

115-
return null;
122+
return status;
116123
}
117124
```
118125

119-
Render `<AutoplayHandler />` inside your `<Player.Provider>` and drop the `autoPlay` attribute from `<Video>` when you do — combining the declarative attribute with a manual `play()` call leads to duplicate attempts and inconsistent state.
126+
Call `useAutoplay()` inside any component nested in `<Player.Provider>`, condition your fallback UI on the returned status, and drop the `autoPlay` attribute from `<Video>` — the hook handles `play()` itself.
127+
128+
```tsx title="HeroPlayer.tsx"
129+
function HeroPlayer() {
130+
const status = useAutoplay();
131+
return (
132+
<Player.Container>
133+
<Video src="hero.mp4" muted playsInline />
134+
{status === 'blocked' && <ClickToPlayOverlay />}
135+
</Player.Container>
136+
);
137+
}
138+
```
120139
</FrameworkCase>
121140

122141
<FrameworkCase frameworks={["html"]}>
123-
<DocsLink slug="reference/player-controller">`PlayerController`</DocsLink> with <DocsLink slug="reference/feature-playback">`selectPlayback`</DocsLink> gives you a `play()` action that returns the same promise, and <DocsLink slug="reference/feature-volume">`selectVolume`</DocsLink> lets you toggle mute on rejection:
124-
125-
```ts title="autoplay-handler.ts"
126-
import {
127-
MediaElement,
128-
PlayerController,
129-
playerContext,
130-
type PropertyValues,
131-
selectPlayback,
132-
selectVolume,
133-
} from '@videojs/html';
142+
The provider element exposes its store on the `<video-player>` instance. Read <DocsLink slug="reference/feature-playback">`selectPlayback`</DocsLink> for the `play()` action, <DocsLink slug="reference/feature-volume">`selectVolume`</DocsLink> for the muted fallback, and `store.target` to gate the call on media being attached:
134143

135-
export type AutoplayStatus = 'playing' | 'muted' | 'blocked';
144+
```html title="index.html"
145+
<video-player>
146+
<media-container>
147+
<video src="hero.mp4" muted playsinline></video>
148+
</media-container>
149+
</video-player>
136150

137-
export class AutoplayHandlerElement extends MediaElement {
138-
static readonly tagName = 'autoplay-handler';
151+
<script type="module">
152+
import '@videojs/html/video/player';
153+
import { selectPlayback, selectVolume } from '@videojs/html';
139154
140-
readonly #playback = new PlayerController(this, playerContext, selectPlayback);
141-
readonly #volume = new PlayerController(this, playerContext, selectVolume);
142-
#attempted = false;
155+
await customElements.whenDefined('video-player');
156+
const player = document.querySelector('video-player');
143157
144-
protected override update(changed: PropertyValues): void {
145-
super.update(changed);
158+
const setStatus = (status) => {
159+
player.dataset.autoplay = status; // drive your fallback UI from CSS
160+
};
146161
147-
const playback = this.#playback.value;
148-
if (this.#attempted || !playback) return;
149-
this.#attempted = true;
162+
const tryPlay = () => {
163+
if (!player.store.target) return false;
164+
const playback = selectPlayback(player.store.state);
165+
if (!playback) return false;
150166
151-
const attempt = async (): Promise<AutoplayStatus> => {
152-
try {
153-
await playback.play();
154-
return 'playing';
155-
} catch {
156-
const volume = this.#volume.value;
167+
playback.play().then(
168+
() => setStatus('playing'),
169+
() => {
170+
const volume = selectVolume(player.store.state);
157171
if (volume && !volume.muted) volume.toggleMuted();
158-
try {
159-
await playback.play();
160-
return 'muted';
161-
} catch {
162-
return 'blocked';
163-
}
164-
}
165-
};
166-
167-
attempt().then((detail) =>
168-
this.dispatchEvent(new CustomEvent('autoplaystatus', { detail, bubbles: true }))
172+
playback.play().then(
173+
() => setStatus('muted'),
174+
() => setStatus('blocked'),
175+
);
176+
},
169177
);
170-
}
171-
}
172-
173-
customElements.define(AutoplayHandlerElement.tagName, AutoplayHandlerElement);
174-
```
175-
176-
Place `<autoplay-handler>` inside `<video-player>` and drop the `autoplay` attribute from your media — combining the declarative attribute with a manual `play()` call leads to duplicate attempts and inconsistent state.
178+
return true;
179+
};
177180
178-
```html title="index.html"
179-
<script type="module">
180-
import '@videojs/html/video/player';
181-
import './autoplay-handler';
181+
if (!tryPlay()) {
182+
const unsub = player.store.subscribe(() => { if (tryPlay()) unsub(); });
183+
}
182184
</script>
183-
184-
<video-player>
185-
<media-container>
186-
<video src="hero.mp4" muted playsinline></video>
187-
</media-container>
188-
<autoplay-handler></autoplay-handler>
189-
</video-player>
190185
```
186+
187+
Drop the `autoplay` attribute from your media when you do this — combining the declarative attribute with a manual `play()` call leads to duplicate attempts and inconsistent state.
191188
</FrameworkCase>
192189

193190
<Aside type="note">
@@ -203,4 +200,4 @@ When even muted autoplay fails, autoplay isn't going to happen without the viewe
203200
- **Don't retry on every render.** The browser's decision is based on engagement state that won't change without viewer input. Retrying in a loop wastes work and inflates analytics.
204201
- **Don't log autoplay rejection as a fatal error.** A blocked autoplay is the browser doing its job. If your analytics treats every `play()` rejection as a playback failure, your error rates will spike on first-visit sessions where the policy hasn't been earned yet. Distinguish a `NotAllowedError` on the initial autoplay attempt from real playback errors.
205202

206-
Wire your UI to whichever status the handler above surfaces — a React state, a custom event, or a store slice of your own — and show the fallback overlay only when status reaches `'blocked'`.
203+
Wire your UI to whichever signal the handler above exposes, and show the fallback overlay only when status reaches `'blocked'`.

0 commit comments

Comments
 (0)