Skip to content

docs(site): add autoplay how-to guide - #1534

Open
decepulis wants to merge 11 commits into
mainfrom
claude/autoplay-guide-doc-fIB5G
Open

docs(site): add autoplay how-to guide#1534
decepulis wants to merge 11 commits into
mainfrom
claude/autoplay-guide-doc-fIB5G

Conversation

@decepulis

@decepulis decepulis commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Adds an autoplay how-to guide (and rounds out some problems Rahim and I saw with usePlayer along the way)

Closes #1854


Note

Low Risk
Documentation-only changes with no runtime or API behavior impact.

Overview
Adds a new How to → Autoplay guide that explains browser autoplay constraints, recommends muted/playsInline, and documents calling play() manually (instead of relying on the autoplay attribute) so apps can handle promise rejections— including React via usePlayer and HTML via the player store—with a table of common DOMException names and notes on default component behavior when playback is blocked.

The usePlayer reference page now opens with a small example contrasting direct store access vs selector subscriptions.

The guide is registered in docs.config.ts under the How-to section.

Reviewed by Cursor Bugbot for commit e98b91d. Bugbot is set up for automated code reviews on this repo. Configure here.

Explains how browser autoplay policies work, why they block playback,
and shows two patterns for handling them — declarative attributes for
the simple case, and a store-routed orchestrator (usePlayer /
PlayerController with selectPlayback and selectVolume) for when you
need to detect failure and fall back to muted retry.

Closes #1039.

https://claude.ai/code/session_01QLEz6mPy8579e4QXq3h1L8
@vercel

vercel Bot commented May 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Jul 27, 2026 11:14pm

Request Review

@netlify

netlify Bot commented May 12, 2026

Copy link
Copy Markdown

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit e98b91d
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/6a67e64d71e99d0008bc0d39
😎 Deploy Preview https://deploy-preview-1534--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

🎨 @videojs/html — no changes
Presets (7)
Entry Initial Lazy
/video (default) 51.12 kB 54.24 kB
/video (default + hls) 190.40 kB 54.24 kB
/video (minimal) 51.11 kB 54.24 kB
/video (minimal + hls) 190.57 kB 54.24 kB
/audio (default) 44.42 kB 54.24 kB
/audio (minimal) 41.63 kB 54.24 kB
/background 4.20 kB
Media (10)
Entry Initial
/media/background-video 1.14 kB
/media/container 1.71 kB
/media/dash-video 214.79 kB
/media/hlsjs-video 141.52 kB
/media/mux-audio 164.00 kB
/media/mux-video 163.70 kB
/media/native-hls-video 9.05 kB
/media/simple-hls-audio-only 19.08 kB
/media/simple-hls-video 21.11 kB
/media/vimeo-video 12.31 kB
Players (5)
Entry Initial
/video/player 8.22 kB
/audio/player 5.38 kB
/background/player 3.92 kB
/live-video/player 7.63 kB
/live-audio/player 5.39 kB
Skins (30)
Entry Type Initial Lazy
/video/minimal-skin.css css 5.70 kB
/video/skin.css css 5.65 kB
/video/minimal-skin js 51.10 kB 54.24 kB
/video/minimal-skin.tailwind js 51.87 kB 54.24 kB
/video/skin js 51.12 kB 54.24 kB
/video/skin.tailwind js 51.87 kB 54.24 kB
/audio/minimal-skin.css css 3.97 kB
/audio/skin.css css 3.86 kB
/audio/minimal-skin js 41.62 kB 54.24 kB
/audio/minimal-skin.tailwind js 42.20 kB 54.24 kB
/audio/skin js 44.42 kB 54.24 kB
/audio/skin.tailwind js 44.91 kB 54.24 kB
/background/skin.css css 133 B
/background/skin js 1.14 kB
/live-video/minimal-skin.css css 5.70 kB
/live-video/skin.css css 5.65 kB
/live-video/minimal-skin js 48.06 kB 54.24 kB
/live-video/minimal-skin.tailwind js 48.56 kB 54.24 kB
/live-video/skin js 49.60 kB 54.24 kB
/live-video/skin.tailwind js 50.22 kB 54.24 kB
/live-audio/minimal-skin.css css 3.97 kB
/live-audio/skin.css css 3.86 kB
/live-audio/minimal-skin js 33.99 kB 54.24 kB
/live-audio/minimal-skin.tailwind js 33.42 kB 54.24 kB
/live-audio/skin js 36.91 kB 54.24 kB
/live-audio/skin.tailwind js 36.40 kB 54.24 kB
/global.css css 183 B
/shared.css css 153 B
/tailwind.css css 161 B
/skin-element js 1.51 kB
UI Components (39)
Entry Initial
/ui/airplay-button 2.57 kB
/ui/alert-dialog 2.95 kB
/ui/alert-dialog-close 2.39 kB
/ui/alert-dialog-description 2.39 kB
/ui/alert-dialog-title 2.34 kB
/ui/audio-track-radio-group 3.00 kB
/ui/buffering-indicator 2.68 kB
/ui/captions-button 2.69 kB
/ui/captions-radio-group 3.05 kB
/ui/cast-button 2.62 kB
/ui/compounds 3.22 kB
/ui/controls 2.93 kB
/ui/error-dialog 2.94 kB
/ui/fullscreen-button 2.60 kB
/ui/hotkey 2.43 kB
/ui/menu 2.97 kB
/ui/mute-button 2.61 kB
/ui/pip-button 2.59 kB
/ui/play-button 2.60 kB
/ui/playback-rate-button 2.66 kB
/ui/playback-rate-radio-group 2.89 kB
/ui/popover 3.14 kB
/ui/poster 2.50 kB
/ui/quality-radio-group 3.03 kB
/ui/seek-button 2.63 kB
/ui/seek-indicator 2.75 kB
/ui/seek-indicator-value 528 B
/ui/slider 2.95 kB
/ui/status-announcer 2.54 kB
/ui/status-indicator 2.68 kB
/ui/status-indicator-value 528 B
/ui/thumbnail 2.64 kB
/ui/time 2.93 kB
/ui/time-slider 2.96 kB
/ui/tooltip 2.94 kB
/ui/volume-indicator 2.59 kB
/ui/volume-indicator-fill 464 B
/ui/volume-indicator-value 463 B
/ui/volume-slider 2.95 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react — no changes
Presets (7)
Entry Initial Lazy
/video (default) 40.75 kB 54.24 kB
/video (default + hls) 178.69 kB 54.24 kB
/video (minimal) 41.12 kB 54.24 kB
/video (minimal + hls) 179.40 kB 54.24 kB
/audio (default) 33.91 kB 54.24 kB
/audio (minimal) 33.96 kB 54.24 kB
/background 579 B
Media (9)
Entry Initial
/media/background-video 403 B
/media/dash-video 212.98 kB
/media/hlsjs-video 139.79 kB
/media/mux-audio 162.38 kB
/media/mux-video 162.41 kB
/media/native-hls-video 7.26 kB
/media/simple-hls-audio-only 17.36 kB
/media/simple-hls-video 19.44 kB
/media/vimeo-video 10.44 kB
Skins (27)
Entry Type Initial Lazy
/tailwind.css css 161 B
/video/minimal-skin.css css 5.55 kB
/video/skin.css css 5.52 kB
/video/minimal-skin js 40.98 kB 54.24 kB
/video/minimal-skin.tailwind js 47.11 kB 54.24 kB
/video/skin js 40.65 kB 54.24 kB
/video/skin.tailwind js 46.79 kB 54.24 kB
/audio/minimal-skin.css css 3.79 kB
/audio/skin.css css 3.68 kB
/audio/minimal-skin js 33.88 kB 54.24 kB
/audio/minimal-skin.tailwind js 36.11 kB 54.24 kB
/audio/skin js 33.86 kB 54.24 kB
/audio/skin.tailwind js 38.06 kB 54.24 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 5.55 kB
/live-video/skin.css css 5.52 kB
/live-video/minimal-skin js 35.43 kB 54.24 kB
/live-video/minimal-skin.tailwind js 41.46 kB 54.24 kB
/live-video/skin js 35.44 kB 54.24 kB
/live-video/skin.tailwind js 41.55 kB 54.24 kB
/live-audio/minimal-skin.css css 3.79 kB
/live-audio/skin.css css 3.68 kB
/live-audio/minimal-skin js 24.25 kB 54.24 kB
/live-audio/minimal-skin.tailwind js 27.42 kB 54.24 kB
/live-audio/skin js 24.31 kB 54.24 kB
/live-audio/skin.tailwind js 27.45 kB 54.24 kB
UI Components (33)
Entry Initial
/ui/airplay-button 2.49 kB
/ui/alert-dialog 2.66 kB
/ui/audio-track 2.30 kB
/ui/buffering-indicator 2.51 kB
/ui/captions-button 2.44 kB
/ui/captions-radio-group 2.28 kB
/ui/cast-button 2.44 kB
/ui/controls 2.48 kB
/ui/error-dialog 2.47 kB
/ui/fullscreen-button 2.46 kB
/ui/gesture 2.31 kB
/ui/hotkey 2.27 kB
/ui/live-button 2.37 kB
/ui/menu 2.60 kB
/ui/mute-button 2.42 kB
/ui/pip-button 2.43 kB
/ui/play-button 2.52 kB
/ui/playback-rate 2.46 kB
/ui/playback-rate-button 2.44 kB
/ui/popover 3.06 kB
/ui/poster 2.42 kB
/ui/quality 2.27 kB
/ui/seek-button 2.42 kB
/ui/seek-indicator 2.60 kB
/ui/slider 2.69 kB
/ui/status-announcer 2.34 kB
/ui/status-indicator 2.38 kB
/ui/thumbnail 2.43 kB
/ui/time 2.27 kB
/ui/time-slider 2.58 kB
/ui/tooltip 3.04 kB
/ui/volume-indicator 2.32 kB
/ui/volume-slider 2.57 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core — no changes
Entries (68)
Entry Initial Lazy
. 10.55 kB
/dom 17.70 kB
/dom/media/custom-media-element 2.09 kB
/dom/media/dash 208.97 kB
/dom/media/google-cast 4.03 kB
/dom/media/hls-js 135.87 kB
/dom/media/media-host 1.25 kB
/dom/media/media-played-ranges 576 B
/dom/media/mux 151.37 kB
/dom/media/native-hls 3.07 kB
/dom/media/simple-hls 18.91 kB
/dom/media/simple-hls-audio-only 16.85 kB
/dom/media/vimeo 9.87 kB
/media/predicate 563 B
/i18n 2.66 kB 54.24 kB
/i18n/locales/all 30.58 kB
/i18n/locales/ar 1.21 kB
/i18n/locales/az 1.08 kB
/i18n/locales/bg 1.25 kB
/i18n/locales/bn 1.27 kB
/i18n/locales/bs 1014 B
/i18n/locales/ca 1.05 kB
/i18n/locales/cs 1.04 kB
/i18n/locales/cy 1.00 kB
/i18n/locales/da 996 B
/i18n/locales/de 1.08 kB
/i18n/locales/el 1.45 kB
/i18n/locales/en 621 B
/i18n/locales/es 1010 B
/i18n/locales/et 1.05 kB
/i18n/locales/eu 1.01 kB
/i18n/locales/fa 1.20 kB
/i18n/locales/fi 1.03 kB
/i18n/locales/fr 1.07 kB
/i18n/locales/gd 1.10 kB
/i18n/locales/gl 1014 B
/i18n/locales/he 1.12 kB
/i18n/locales/hi 1.28 kB
/i18n/locales/hr 1.02 kB
/i18n/locales/hu 1.09 kB
/i18n/locales/it 1023 B
/i18n/locales/ja 1.17 kB
/i18n/locales/ko 1.11 kB
/i18n/locales/lv 1.08 kB
/i18n/locales/mr 1.28 kB
/i18n/locales/nb 1000 B
/i18n/locales/ne 1.28 kB
/i18n/locales/nl 1013 B
/i18n/locales/nn 996 B
/i18n/locales/oc 1.05 kB
/i18n/locales/pl 1.12 kB
/i18n/locales/pt 1.01 kB
/i18n/locales/pt-BR 1.01 kB
/i18n/locales/pt-PT 1009 B
/i18n/locales/ro 1.05 kB
/i18n/locales/ru 1.33 kB
/i18n/locales/sk 1.10 kB
/i18n/locales/sl 1.02 kB
/i18n/locales/sr 1.03 kB
/i18n/locales/sv 1022 B
/i18n/locales/te 1.31 kB
/i18n/locales/th 1.29 kB
/i18n/locales/tr 1.07 kB
/i18n/locales/uk 1.36 kB
/i18n/locales/vi 1.08 kB
/i18n/locales/zh 1.01 kB
/i18n/locales/zh-CN 1.01 kB
/i18n/locales/zh-TW 1.01 kB
🏷️ @videojs/element — no changes
Entries (2)
Entry Initial
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Initial
. 1.39 kB
/html 696 B
/react 360 B
🔧 @videojs/utils — no changes
Entries (11)
Entry Initial
/array 104 B
/dom 2.74 kB
/events 319 B
/function 327 B
/object 275 B
/predicate 265 B
/percent 281 B
/string 231 B
/style 190 B
/time 813 B
/number 158 B
📦 @videojs/spf — no changes
Entries (4)
Entry Initial
. 4.45 kB
/dom 6.48 kB
/hls 17.88 kB
/background-video 13.27 kB

ℹ️ How to interpret

JS sizes are initial static graph totals (minified + brotli). Lazy dynamic chunks are shown separately when present.

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current initial sizes.

Fixes a TypeScript syntax error in the HTML AutoplayHandlerElement
snippet (`typeof this.#playback.value` isn't valid in a type query),
restructures #tryPlay to re-read the controller value instead.

Also tightens prose throughout: drops a filler 'just', adds a missing
oxford comma, replaces a dangling 'doesn't' with 'fails', collapses
parallel intro paragraphs into the surrounding FrameworkCase blocks
so the markup tracks the reader path more cleanly.

Verified by extracting both snippets into the site source and running
`pnpm astro check` — 0 errors.

https://claude.ai/code/session_01QLEz6mPy8579e4QXq3h1L8
Flattens both handlers so the success/failure tree reads top to bottom
without nested catches or method hops.

React: switches from .then().catch() to .then(success, failure), drops
the Ref suffix on the one-shot guard.

HTML: collapses #tryPlay and #emit into update() via an inline async
attempt() that returns the status, dispatched once.

Both still verified against `pnpm astro check`.

https://claude.ai/code/session_01QLEz6mPy8579e4QXq3h1L8
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
mihar-22

This comment was marked as outdated.

@decepulis

Copy link
Copy Markdown
Collaborator Author

@mihar-22 skipping the rest of your comments for now. This is still a draft and a lot of that is going to change. If we even pursue this guide; sounds like we're reopening the "fix this in code instead of guides" conversation again.

…-doc-fIB5G

# Conflicts:
#	site/src/docs.config.ts
Restructure the autoplay how-to around detecting and recovering from
blocked playback, with corrected React (flat-state selector) and HTML
(store.state) examples. Add createPlayer/usePlayer snippets to the
use-player reference and fix the standalone import.
@decepulis
decepulis marked this pull request as ready for review July 21, 2026 00:13

## Detect and recover from blocked autoplay

The `autoplay` attribute is fire-and-forget: the browser plays your video or quietly does nothing. To know which happened so you can retry muted, to log the outcome, or to show a "click to play" overlay, call `play()` yourself. It returns a promise that resolves on success and rejects when the browser blocks playback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second sentence reads a little cumbersome.

suggest:

The autoplay attribute is fire-and-forget: the browser plays your video or quietly does nothing. Instead, call play() so you can retry muted, log the outcome, or show a “click to play” overlay. It returns a promise that resolves on success and rejects when the browser blocks playback.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded

<FrameworkCase frameworks={["html"]}>
```js
const player = document.querySelector('video-player');
player.store.state.play().then(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: player.store.state.play() feels pretty verbose, and state.play() feels a little off. Would love to revisit this at some point. I think it'd be part of the HTML API conversation we didn't make it to last week.

```
</FrameworkCase>

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggest: Move this above the code snippet so it doesn't get missed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

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.

<Aside type="note">
A rejected `play()` promise is a [`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) whose `name` tells you the cause. Two are standardized: `NotAllowedError`, the autoplay policy blocking playback, and `NotSupportedError`, when the source isn't a supported media format. Some browsers also reject with `AbortError` when an unrelated call interrupts playback. Most handling can treat every rejection the same; to log only true autoplay failures, branch on `error.name === 'NotAllowedError'`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggest: Make this part of the example code. Check for NotAllowedError specifically.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got rid of the aside and reorganized it into something more tolerable, including moving NotAllowedError into the example code


When muted autoplay fails too, playback won't start without the viewer. Treat that as expected, not as an error:

- **Keep the poster visible.** The player isn't loading, it's waiting. Don't show a buffering spinner.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't the reader's job, it's Video.js's.

suggest: Verify video.js handles this well when using the code above, and then tell the reader that's video.js will do for them.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

When muted autoplay fails too, playback won't start without the viewer. Treat that as expected, not as an error:

- **Keep the poster visible.** The player isn't loading, it's waiting. Don't show a buffering spinner.
- **Expose a clear play affordance.** A play button over the poster is the conventional pattern. The player's own controls already do this, so make sure they render in the blocked state.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

play affordance

suggest: less fancy wordses. If we had a reading-level target/checker on these docs I'd expect this to fail.

A play button over the poster is the conventional pattern.

suggest: Also frame this as what the default player skin should do in this case rather than giving them a chore to figure out

note: We should have more to say about background videos specifically, probably in the bg video doc, and understand clearly how the background video media works in each of the problem scenarios.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love me some affordances. Hate me some jargon. Not sure how that slipped in, thanks.

In the case of background videos... by default the media is autoplay muted. Which should work in every scenario... but... what if it doesn't? Hmm...


- **Keep the poster visible.** The player isn't loading, it's waiting. Don't show a buffering spinner.
- **Expose a clear play affordance.** A play button over the poster is the conventional pattern. The player's own controls already do this, so make sure they render in the blocked state.
- **Don't log a blocked autoplay as a fatal error.** It's the browser doing its job. If your analytics counts every `play()` rejection as a playback failure, your error rates will spike on first visits where the policy hasn't been earned yet. Distinguish a `NotAllowedError` on the initial attempt from real playback errors.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure where this would happen. The error doesn't bubble automatically? But I feel like we should give them a full example code that does this for them.

Distinguish a NotAllowedError on the initial attempt

I don't think the "initial attempt" is special compared to later attempts firing this error?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc the intention here was to discourage people from logging an error if it's just NotAllowedError. Instead, they should try again muted or offer a button. Made that more clear.

- **Expose a clear play affordance.** A play button over the poster is the conventional pattern. The player's own controls already do this, so make sure they render in the blocked state.
- **Don't log a blocked autoplay as a fatal error.** It's the browser doing its job. If your analytics counts every `play()` rejection as a playback failure, your error rates will spike on first visits where the policy hasn't been earned yet. Distinguish a `NotAllowedError` on the initial attempt from real playback errors.

Wire your fallback UI to the status the handler exposes, and show the overlay only once it reaches `'blocked'`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reaches 'blocked'

Is this a formal state somewhere or a made up one?

Wire your fallback UI

This part makes it feel like a half solution. I feel like we could either show them an example UI for this purpose, or assume/verify that the existing player UI handles it fine already when autoplay is blocked, i.e. shows the controls.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Pared this way back and made clear that our player ui components show the controls for them

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docs: Autoplay How-To Guide

4 participants