Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/src/content/docs/concepts/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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

Expand Down
251 changes: 251 additions & 0 deletions site/src/content/docs/how-to/build-your-own-component.mdx
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.
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.

The useButton hook is mentioned for button-like controls but not used below.


</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',
}}
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.

To keep in the spirit of the library maybe we should use data attrs and CSS? E.g., data-visible

>
Skip intro
</button>
);
}
```

</FrameworkCase>

<FrameworkCase frameworks={["html"]}>

```ts
import { ReactiveElement } from '@videojs/element';
import { PlayerController, playerContext, selectTime, selectPlayback } from '@videojs/html';
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 think we should re-export ReactiveElement from html package (if we aren't yet). We also have MediaElement which extends ReactiveElement and has destroy lifecycle baked in.


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);
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.

Not cleaned up

}

update() {
super.update();
const time = this.#time.value;
const playback = this.#playback.value;
if (!time || !playback) return;
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.

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';
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.

There's a data-visible attribute, we should move the opacity and pointer-events to CSS.

}

#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>
2 changes: 1 addition & 1 deletion site/src/docs.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const sidebar: Sidebar = [
},
{
sidebarLabel: 'How to',
contents: [{ slug: 'how-to/customize-skins' }],
contents: [{ slug: 'how-to/customize-skins' }, { slug: 'how-to/build-your-own-component' }],
},
{
sidebarLabel: 'Components',
Expand Down
Loading