diff --git a/site/src/content/docs/concepts/overview.mdx b/site/src/content/docs/concepts/overview.mdx index e9d4caba8..8251323c7 100644 --- a/site/src/content/docs/concepts/overview.mdx +++ b/site/src/content/docs/concepts/overview.mdx @@ -106,6 +106,7 @@ If you want more control than skins offer you, you can build your own UI from ou To get started with UI components, you might consider ejecting a skin and using its pre-styled components as a foundation. Learn more about UI components +Build your own component ## 3. Media diff --git a/site/src/content/docs/how-to/build-your-own-component.mdx b/site/src/content/docs/how-to/build-your-own-component.mdx new file mode 100644 index 000000000..e67efe2e8 --- /dev/null +++ b/site/src/content/docs/how-to/build-your-own-component.mdx @@ -0,0 +1,309 @@ +--- +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'; +import DocsLinkCard from '@/components/docs/DocsLinkCard.astro'; +import { TabsRoot, TabsList, TabsPanel, Tab } from '@/components/Tabs.tsx'; + +Custom components subscribe to player state and dispatch actions, like built-in controls. + +## You might not need a custom component + +Before building from scratch, check if an existing approach covers your use case: + + +- **Change what a control renders**: use the `render` prop on any built-in component. See UI components. +- **Restyle a control**: use CSS custom properties and data attributes. See UI components. +- **Rearrange or remove controls**: eject a skin and modify it. See Customize skins. + + + +- **Restyle a control**: use CSS custom properties and data attributes. See UI components. +- **Rearrange or remove controls**: eject a skin and modify it. See Customize skins. + + +Build a custom component when you need new behavior, a new state display, or integration with an external system. + +## Use player state and actions + +Need access to player state or actions? You'll want to familiarize yourself with features. Each feature exposes a set. Here are some features you might reach for first: + +| State | Actions | Feature | +|-------|---------|---------| +| `paused`, `ended` | `play()`, `pause()` | Playback | +| `currentTime`, `duration` | `seek()` | Time | +| `volume`, `muted` | `setVolume()`, `toggleMuted()` | Volume | +| `fullscreen` | `requestFullscreen()`, `exitFullscreen()` | Fullscreen | + +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 Features for details. + + + +Access state and actions with `usePlayer`: + +```tsx +import { usePlayer } from '@videojs/react'; + +// Subscribe to state — re-renders only when selected values change +const paused = usePlayer((s) => s.paused); +const currentTime = usePlayer((s) => s.currentTime); + +// Get the store for dispatching actions (does not subscribe) +const store = usePlayer(); + +await store.play(); +store.setVolume(0.5); +store.seek(30); +``` + + + + + +Access state and actions with `PlayerController` 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); +``` + + + +## Place your component in the player + + + +Your component needs to be inside `` to access state. Place it inside `` if it should also participate in fullscreen and respond to user activity: + +```tsx + // [!code focus] + // [!code focus] + + + // [!code focus] + // [!code focus] + // [!code focus] +``` + + + + + +Your element needs to be inside `` to access state. Place it inside `` if it should also participate in fullscreen and respond to user activity. `` slots its children into ``, so a child of the skin works: + +```html + + + + Skip intro + + +``` + +Extend `MediaElement` from `@videojs/html` so `PlayerController` can schedule DOM updates when state changes: + +```ts +import { MediaElement, PlayerController, playerContext, selectTime, type PropertyValues } from '@videojs/html'; + +class SkipIntroButtonElement extends MediaElement { + #player = new PlayerController(this, playerContext, selectTime); + + update(changed: PropertyValues) { + super.update(changed); + const time = this.#player.value; + } +} +``` + + + +## Make it accessible + +Learn more about accessibility in video players + +## Full example + +A "skip intro" button that appears during the first 30 seconds of playback and seeks past the intro when clicked. + + + + + + SkipIntroButton.tsx + SkipIntroButton.css + + + ```tsx + import { usePlayer } from '@videojs/react'; + + function SkipIntroButton() { + const store = usePlayer(); + const currentTime = usePlayer((s) => s.currentTime); + const paused = usePlayer((s) => s.paused); + + const visible = currentTime < 30 && !paused; + + return ( + + ); + } + ``` + + + ```css + .skip-intro-button { + position: absolute; + bottom: 5rem; + right: 1rem; + opacity: 0; + pointer-events: none; + transition: opacity 200ms; + } + + .skip-intro-button[data-visible] { + opacity: 1; + pointer-events: auto; + } + ``` + + + + + + + + + + skip-intro-button.ts + skip-intro-button.css + + + ```ts + import { + MediaElement, + PlayerController, + playerContext, + selectTime, + selectPlayback, + type PropertyValues, + } from '@videojs/html'; + + class SkipIntroButtonElement extends MediaElement { + #time = new PlayerController(this, playerContext, selectTime); + #playback = new PlayerController(this, playerContext, selectPlayback); + #disconnect: AbortController | null = null; + + connectedCallback() { + super.connectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = new AbortController(); + const { signal } = this.#disconnect; + + this.setAttribute('role', 'button'); + this.setAttribute('aria-label', 'Skip intro'); + this.setAttribute('tabindex', '0'); + this.addEventListener('click', this.#handleActivate, { signal }); + this.addEventListener('keydown', this.#handleKeydown, { signal }); + this.addEventListener('keyup', this.#handleKeyup, { signal }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + // Removes all listeners registered with this signal + this.#disconnect?.abort(); + this.#disconnect = null; + } + + update(changed: PropertyValues) { + super.update(changed); + const time = this.#time.value; + const playback = this.#playback.value; + + // Features are configured per-player, so a feature may not be available + if (!time || !playback) return; + + const visible = time.currentTime < 30 && !playback.paused; + this.toggleAttribute('data-visible', visible); + this.setAttribute('tabindex', visible ? '0' : '-1'); + } + + #handleActivate = () => { + this.#time.value?.seek(30); + }; + + #handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + this.#handleActivate(); + } else if (event.key === ' ') { + // Prevent Space from scrolling the page + event.preventDefault(); + } + }; + + // ARIA button pattern: Space activates on keyup, not keydown + #handleKeyup = (event: KeyboardEvent) => { + if (event.key === ' ') { + this.#handleActivate(); + } + }; + } + + customElements.define('skip-intro-button', SkipIntroButtonElement); + ``` + + + ```css + skip-intro-button { + position: absolute; + bottom: 5rem; + right: 1rem; + opacity: 0; + pointer-events: none; + transition: opacity 200ms; + } + + skip-intro-button[data-visible] { + opacity: 1; + pointer-events: auto; + } + ``` + + + + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index 9009c2498..7457dc83d 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -45,7 +45,7 @@ export const sidebar: Sidebar = [ sidebarLabel: 'How to', llmsDescription: 'Task-oriented guides with step-by-step instructions to achieve a specific outcome by applying one or more concepts. Each guide may assume you already understand the relevant concepts.', - contents: [{ slug: 'how-to/customize-skins' }], + contents: [{ slug: 'how-to/customize-skins' }, { slug: 'how-to/build-your-own-component' }], }, { sidebarLabel: 'Components',