-
Notifications
You must be signed in to change notification settings - Fork 59
docs(site): add how-to guide for building custom components #1008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
99a27b1
bbc2d32
94c3b03
0446c76
a16ab98
405acb2
8bb1726
54837b7
09b3376
d869725
c5dac41
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| --- | ||
| title: Build your own UI component | ||
| description: Create custom player controls that read state, dispatch actions, and stay accessible. | ||
| --- | ||
|
|
||
| import FrameworkCase from '@/components/docs/FrameworkCase.astro'; | ||
| import DocsLink from '@/components/docs/DocsLink.astro'; | ||
|
|
||
| Custom components subscribe to player state and dispatch actions, just like built-in controls. | ||
|
|
||
| ## You might not need a custom component | ||
|
|
||
| Before building from scratch, check if an existing approach covers your use case: | ||
|
|
||
| <FrameworkCase frameworks={["react"]}> | ||
| - **Change what a control renders** — use the `render` prop on any built-in component. See <DocsLink slug="concepts/ui-components">UI components</DocsLink>. | ||
| - **Restyle a control** — use CSS custom properties and data attributes. See <DocsLink slug="concepts/ui-components">UI components</DocsLink>. | ||
| - **Rearrange or remove controls** — eject a skin and modify it. See <DocsLink slug="how-to/customize-skins">Customize skins</DocsLink>. | ||
| </FrameworkCase> | ||
|
|
||
| <FrameworkCase frameworks={["html"]}> | ||
| - **Restyle a control** — use CSS custom properties and data attributes. See <DocsLink slug="concepts/ui-components">UI components</DocsLink>. | ||
| - **Rearrange or remove controls** — eject a skin and modify it. See <DocsLink slug="how-to/customize-skins">Customize skins</DocsLink>. | ||
| </FrameworkCase> | ||
|
|
||
| Build a custom component when you need new behavior, a new state display, or integration with an external system. | ||
|
|
||
| ## Use player state and actions | ||
|
|
||
| Each <DocsLink slug="concepts/features">feature</DocsLink> exposes state properties and actions. Common features: | ||
|
|
||
| | State | Actions | Feature | | ||
| |-------|---------|---------| | ||
| | `paused`, `ended` | `play()`, `pause()` | <DocsLink slug="reference/feature-playback">Playback</DocsLink> | | ||
| | `currentTime`, `duration` | `seek()` | <DocsLink slug="reference/feature-time">Time</DocsLink> | | ||
| | `volume`, `muted` | `setVolume()`, `toggleMuted()` | <DocsLink slug="reference/feature-volume">Volume</DocsLink> | | ||
| | `fullscreen` | `requestFullscreen()`, `exitFullscreen()` | <DocsLink slug="reference/feature-fullscreen">Fullscreen</DocsLink> | | ||
|
|
||
| Browse the full list in the **Features** section of the sidebar. | ||
|
|
||
| Each feature also has an **availability** property (`'available'`, `'unavailable'`, or `'unsupported'`) for hiding controls the platform does not support. See <DocsLink slug="concepts/features">Features</DocsLink> for details. | ||
|
|
||
| <FrameworkCase frameworks={["react"]}> | ||
|
|
||
| Access state and actions with <DocsLink slug="reference/use-player">`Player.usePlayer`</DocsLink>: | ||
|
|
||
| ```tsx | ||
| // Subscribe to state — re-renders only when selected values change | ||
| const paused = Player.usePlayer((s) => s.paused); | ||
| const currentTime = Player.usePlayer((s) => s.currentTime); | ||
|
|
||
| // Get the store for dispatching actions (does not subscribe) | ||
| const store = Player.usePlayer(); | ||
|
|
||
| await store.play(); | ||
| store.setVolume(0.5); | ||
| store.seek(30); | ||
| ``` | ||
|
|
||
| </FrameworkCase> | ||
|
|
||
| <FrameworkCase frameworks={["html"]}> | ||
|
|
||
| Access state and actions with <DocsLink slug="reference/player-controller">`PlayerController`</DocsLink> and a feature selector: | ||
|
|
||
| ```ts | ||
| import { PlayerController, playerContext, selectPlayback } from '@videojs/html'; | ||
|
|
||
| // Subscribe to a feature — triggers update() when its state changes | ||
| #playback = new PlayerController(this, playerContext, selectPlayback); | ||
|
|
||
| // In update(): | ||
| const playback = this.#playback.value; | ||
| if (playback?.paused) { | ||
| playback.play(); | ||
| } | ||
| ``` | ||
|
|
||
| Each selector returns both state and actions for that feature. Use separate controllers when you need multiple features (the [full example](#full-example) demonstrates this). | ||
|
|
||
| Without a selector, `PlayerController` returns the full store without subscribing to changes: | ||
|
|
||
| ```ts | ||
| #store = new PlayerController(this, playerContext); | ||
|
|
||
| // Call any action | ||
| this.#store.value.play(); | ||
| this.#store.value.setVolume(0.5); | ||
| ``` | ||
|
|
||
| </FrameworkCase> | ||
|
|
||
| ## Place your component in the player | ||
|
|
||
| <FrameworkCase frameworks={["react"]}> | ||
|
|
||
| Your component needs to be inside <DocsLink slug="reference/player-provider">`<Player.Provider>`</DocsLink> to access state. Place it inside <DocsLink slug="reference/player-container">`<Player.Container>`</DocsLink> if it should also participate in fullscreen and respond to user activity: | ||
|
|
||
| ```tsx | ||
| <Player.Provider> | ||
| <Player.Container> | ||
| <VideoSkin> | ||
| <Video src="video.mp4" /> | ||
| </VideoSkin> | ||
| <SkipIntroButton /> {/* Your custom component */} | ||
| </Player.Container> | ||
| </Player.Provider> | ||
| ``` | ||
|
|
||
| </FrameworkCase> | ||
|
|
||
| <FrameworkCase frameworks={["html"]}> | ||
|
|
||
| Your element needs to be inside <DocsLink slug="reference/player-provider">`<video-player>`</DocsLink> to access state: | ||
|
|
||
| ```html | ||
| <video-player> | ||
| <video-skin> | ||
| <video slot="media" src="video.mp4"></video> | ||
| </video-skin> | ||
| <skip-intro-button></skip-intro-button> <!-- Your custom element --> | ||
| </video-player> | ||
| ``` | ||
|
|
||
| Extend `ReactiveElement` from `@videojs/element` so `PlayerController` can schedule DOM updates when state changes: | ||
|
|
||
| ```ts | ||
| import { ReactiveElement } from '@videojs/element'; | ||
| import { PlayerController, playerContext, selectTime } from '@videojs/html'; | ||
|
|
||
| class SkipIntroButtonElement extends ReactiveElement { | ||
| #player = new PlayerController(this, playerContext, selectTime); | ||
|
|
||
| update() { | ||
| super.update(); | ||
| // React to state changes here | ||
| } | ||
| } | ||
|
|
||
| customElements.define('skip-intro-button', SkipIntroButtonElement); | ||
| ``` | ||
|
|
||
| </FrameworkCase> | ||
|
|
||
| ## Make it accessible | ||
|
|
||
| Use semantic HTML elements (`<button>`, not `<div>`), add ARIA attributes where needed, and support keyboard interaction. | ||
|
|
||
| TODO: Link to concepts/accessibility once that page is merged | ||
|
|
||
| <FrameworkCase frameworks={["react"]}> | ||
|
|
||
| For button-like controls, the <DocsLink slug="reference/use-button">`useButton`</DocsLink> hook handles keyboard activation (Enter and Space) and ARIA attributes. | ||
|
|
||
| </FrameworkCase> | ||
|
|
||
| ## Full example | ||
|
|
||
| A "skip intro" button that appears during the first 30 seconds of playback and seeks past the intro when clicked. | ||
|
|
||
| <FrameworkCase frameworks={["react"]}> | ||
|
|
||
| ```tsx | ||
| function SkipIntroButton() { | ||
| const store = Player.usePlayer(); | ||
| const currentTime = Player.usePlayer((s) => s.currentTime); | ||
| const paused = Player.usePlayer((s) => s.paused); | ||
|
|
||
| const visible = currentTime < 30 && !paused; | ||
|
|
||
| return ( | ||
| <button | ||
| onClick={() => store.seek(30)} | ||
| aria-label="Skip intro" | ||
| style={{ | ||
| position: 'absolute', | ||
| bottom: '5rem', | ||
| right: '1rem', | ||
| opacity: visible ? 1 : 0, | ||
| pointerEvents: visible ? 'auto' : 'none', | ||
| transition: 'opacity 200ms', | ||
| }} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To keep in the spirit of the library maybe we should use data attrs and CSS? E.g., |
||
| > | ||
| Skip intro | ||
| </button> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| </FrameworkCase> | ||
|
|
||
| <FrameworkCase frameworks={["html"]}> | ||
|
|
||
| ```ts | ||
| import { ReactiveElement } from '@videojs/element'; | ||
| import { PlayerController, playerContext, selectTime, selectPlayback } from '@videojs/html'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should re-export |
||
|
|
||
| class SkipIntroButtonElement extends ReactiveElement { | ||
| static tagName = 'skip-intro-button'; | ||
|
|
||
| #time = new PlayerController(this, playerContext, selectTime); | ||
| #playback = new PlayerController(this, playerContext, selectPlayback); | ||
|
|
||
| connectedCallback() { | ||
| super.connectedCallback(); | ||
| this.setAttribute('role', 'button'); | ||
| this.setAttribute('aria-label', 'Skip intro'); | ||
| this.setAttribute('tabindex', '0'); | ||
| this.addEventListener('click', this.#handleActivate); | ||
| this.addEventListener('keydown', this.#handleKeydown); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not cleaned up |
||
| } | ||
|
|
||
|
|
||
| update() { | ||
| super.update(); | ||
| const time = this.#time.value; | ||
| const playback = this.#playback.value; | ||
| if (!time || !playback) return; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: new line above suggestion: Comment briefly explaining why these can be undefined (store features are configured so some features might be missing) |
||
|
|
||
| const visible = time.currentTime < 30 && !playback.paused; | ||
| this.toggleAttribute('data-visible', visible); | ||
| this.style.opacity = visible ? '1' : '0'; | ||
| this.style.pointerEvents = visible ? 'auto' : 'none'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a |
||
| } | ||
|
|
||
|
|
||
| #handleActivate = () => { | ||
| this.#time.value?.seek(30); | ||
| }; | ||
|
|
||
| #handleKeydown = (event: KeyboardEvent) => { | ||
| if (event.key === 'Enter' || event.key === ' ') { | ||
|
|
||
| event.preventDefault(); | ||
| this.#handleActivate(); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| customElements.define('skip-intro-button', SkipIntroButtonElement); | ||
| ``` | ||
|
|
||
| Style it with CSS: | ||
|
|
||
| ```css | ||
| skip-intro-button { | ||
| position: absolute; | ||
| bottom: 5rem; | ||
| right: 1rem; | ||
| transition: opacity 200ms; | ||
| } | ||
| ``` | ||
|
|
||
| </FrameworkCase> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
useButtonhook is mentioned for button-like controls but not used below.