mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
docs(site): add how-to guide for building custom components (#1008)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
466abb6d20
commit
b830baee6b
@@ -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 <DocsLink slug="how-to/customize-skins">ejecting a skin</DocsLink> and using its pre-styled components as a foundation.
|
||||
|
||||
<DocsLinkCard slug="concepts/ui-components">Learn more about UI components</DocsLinkCard>
|
||||
<DocsLinkCard slug="how-to/build-your-own-component">Build your own component</DocsLinkCard>
|
||||
|
||||
## 3. Media
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
<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
|
||||
|
||||
Need access to player state or actions? You'll want to familiarize yourself with <DocsLink slug="concepts/features">features</DocsLink>. Each feature exposes a set. Here are some features you might reach for first:
|
||||
|
||||
| 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">`usePlayer`</DocsLink>:
|
||||
|
||||
```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);
|
||||
```
|
||||
|
||||
</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> // [!code focus]
|
||||
<Player.Container> // [!code focus]
|
||||
<VideoSkin>
|
||||
<Video src="video.mp4" />
|
||||
</VideoSkin>
|
||||
<SkipIntroButton /> // [!code focus]
|
||||
</Player.Container> // [!code focus]
|
||||
</Player.Provider> // [!code focus]
|
||||
```
|
||||
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
|
||||
Your element needs to be inside <DocsLink slug="reference/player-provider">`<video-player>`</DocsLink> to access state. Place it inside <DocsLink slug="reference/player-container">`<media-container>`</DocsLink> if it should also participate in fullscreen and respond to user activity. `<video-skin>` slots its children into `<media-container>`, so a child of the skin works:
|
||||
|
||||
```html
|
||||
<video-player> <!-- [!code focus] -->
|
||||
<video-skin> <!-- [!code focus] -->
|
||||
<video slot="media" src="video.mp4"></video>
|
||||
<skip-intro-button>Skip intro</skip-intro-button> <!-- [!code focus] -->
|
||||
</video-skin> <!-- [!code focus] -->
|
||||
</video-player> <!-- [!code focus] -->
|
||||
```
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</FrameworkCase>
|
||||
|
||||
## Make it accessible
|
||||
|
||||
<DocsLinkCard slug="concepts/accessibility">Learn more about accessibility in video players</DocsLinkCard>
|
||||
|
||||
## 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"]}>
|
||||
|
||||
<TabsRoot client:idle>
|
||||
<TabsList client:idle label="React skip intro example">
|
||||
<Tab client:idle value="component" initial>SkipIntroButton.tsx</Tab>
|
||||
<Tab client:idle value="css">SkipIntroButton.css</Tab>
|
||||
</TabsList>
|
||||
<TabsPanel client:idle value="component" initial>
|
||||
```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 (
|
||||
<button
|
||||
className="skip-intro-button"
|
||||
onClick={() => store.seek(30)}
|
||||
aria-label="Skip intro"
|
||||
// `undefined` removes the attribute; `false` would render data-visible="false"
|
||||
data-visible={visible || undefined}
|
||||
tabIndex={visible ? 0 : -1}
|
||||
>
|
||||
Skip intro
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
</TabsPanel>
|
||||
<TabsPanel client:idle value="css">
|
||||
```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;
|
||||
}
|
||||
```
|
||||
</TabsPanel>
|
||||
</TabsRoot>
|
||||
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
|
||||
<TabsRoot client:idle>
|
||||
<TabsList client:idle label="HTML skip intro example">
|
||||
<Tab client:idle value="ts" initial>skip-intro-button.ts</Tab>
|
||||
<Tab client:idle value="css">skip-intro-button.css</Tab>
|
||||
</TabsList>
|
||||
<TabsPanel client:idle value="ts" initial>
|
||||
```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);
|
||||
```
|
||||
</TabsPanel>
|
||||
<TabsPanel client:idle value="css">
|
||||
```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;
|
||||
}
|
||||
```
|
||||
</TabsPanel>
|
||||
</TabsRoot>
|
||||
|
||||
</FrameworkCase>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user