docs(site): add thumbnail reference page (#654)

This commit is contained in:
rahim
2026-02-28 00:40:44 -08:00
committed by GitHub
parent ed43d52f8f
commit e63d591d6e
20 changed files with 532 additions and 2 deletions
+22
View File
@@ -15,6 +15,11 @@ export interface PlayerContextValue {
}
const PlayerContext = createContext<PlayerContextValue | null>(null);
const EMPTY_UNSUBSCRIBE = () => {};
const EMPTY_STORE = {
state: {} as UnknownState,
subscribe: () => EMPTY_UNSUBSCRIBE,
} as Pick<UnknownStore, 'state' | 'subscribe'>;
export function PlayerContextProvider({
value,
@@ -51,6 +56,23 @@ export function usePlayer<R>(selector?: (state: UnknownState) => R) {
return useStore(store, selector as any);
}
/**
* Access player state when available, but return `undefined` outside Provider.
*
* This is useful for components that can operate without player context
* (e.g. they accept fully explicit props as a fallback).
*/
/** @label Without Selector */
export function useOptionalPlayer(): UnknownStore | undefined;
/** @label With Selector */
export function useOptionalPlayer<R>(selector: (state: UnknownState) => R): R | undefined;
export function useOptionalPlayer<R>(selector?: (state: UnknownState) => R) {
const ctx = useContext(PlayerContext);
const store = (ctx?.store ?? (EMPTY_STORE as unknown as UnknownStore)) as UnknownStore;
const value = useStore(store, (ctx ? selector : undefined) as any);
return ctx ? value : undefined;
}
/** Access the media element from within a Player Provider. */
export function useMedia(): Media | null {
const { media } = usePlayerContext();
@@ -8,6 +8,7 @@ import {
type PlayerContextValue,
useMedia,
useMediaRegistration,
useOptionalPlayer,
usePlayer,
usePlayerContext,
} from '../context';
@@ -83,6 +84,47 @@ describe('usePlayer', () => {
});
});
describe('useOptionalPlayer', () => {
it('returns undefined outside Provider', () => {
const { result } = renderHook(() => useOptionalPlayer());
expect(result.current).toBeUndefined();
});
it('returns undefined outside Provider with selector', () => {
const { result } = renderHook(() => useOptionalPlayer((state: any) => state.paused));
expect(result.current).toBeUndefined();
});
it('does not run selector outside Provider', () => {
const selector = vi.fn(() => true);
const { result } = renderHook(() => useOptionalPlayer(selector));
expect(result.current).toBeUndefined();
expect(selector).not.toHaveBeenCalled();
});
it('returns store inside Provider', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const { result } = renderHook(() => useOptionalPlayer(), {
wrapper: createWrapper(value),
});
expect(result.current).toBe(store);
});
it('returns selected state inside Provider', () => {
const store = createMockStore();
const value: PlayerContextValue = { store: store as any, media: null, setMedia: vi.fn() };
const { result } = renderHook(() => useOptionalPlayer((state: any) => state.paused), {
wrapper: createWrapper(value),
});
expect(result.current).toBe(true);
});
});
describe('useMedia', () => {
it('returns media from context', () => {
const store = createMockStore();
@@ -11,7 +11,7 @@ import { createThumbnail, selectTextTrack } from '@videojs/core/dom';
import type { CSSProperties } from 'react';
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import { usePlayer } from '../../player/context';
import { useOptionalPlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
@@ -36,7 +36,7 @@ export const Thumbnail = forwardRef<HTMLDivElement, ThumbnailProps>(function Thu
const [core] = useState(() => new ThumbnailCore());
const divRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const textTrack = usePlayer(selectTextTrack);
const textTrack = useOptionalPlayer(selectTextTrack);
// Force re-render when the handle's state changes (img load/error, resize).
const [, setRenderToken] = useState(0);
@@ -0,0 +1,10 @@
WEBVTT
00:00:00.000 --> 00:00:10.000
https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=0
00:00:10.000 --> 00:00:20.000
https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=10
00:00:20.000 --> 00:00:30.000
https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=20
@@ -0,0 +1,10 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './BasicUsage.html?raw';
import './BasicUsage.css';
---
<HtmlDemo html={html} />
<script>
import './BasicUsage.ts';
</script>
@@ -0,0 +1,24 @@
.html-thumbnail-text-track {
position: relative;
display: block;
max-width: 280px;
}
.html-thumbnail-text-track__media {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.html-thumbnail-text-track__thumbnail {
display: block;
width: auto;
min-width: 0;
max-width: 240px;
}
.html-thumbnail-text-track__thumbnail[data-hidden] {
display: none;
}
@@ -0,0 +1,22 @@
<section class="html-thumbnail-text-track">
<video-player>
<media-container>
<video
class="html-thumbnail-text-track__media"
src="https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4"
preload="auto"
muted
playsinline
crossorigin="anonymous"
>
<track
kind="metadata"
label="thumbnails"
src="/docs/demos/thumbnail/basic.vtt"
default
/>
</video>
<media-thumbnail class="html-thumbnail-text-track__thumbnail" time="12"></media-thumbnail>
</media-container>
</video-player>
</section>
@@ -0,0 +1,2 @@
import '@videojs/html/video/player';
import '@videojs/html/ui/thumbnail';
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './JsonSpriteUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import './JsonSpriteUsage.ts';
</script>
@@ -0,0 +1 @@
<media-thumbnail time="12" style="max-width: 240px;"></media-thumbnail>
@@ -0,0 +1,41 @@
import '@videojs/html/ui/thumbnail';
type DemoThumbnailImage = {
url: string;
startTime: number;
endTime?: number;
width?: number;
height?: number;
coords?: { x: number; y: number };
};
type ThumbnailDemoElement = HTMLElement & { thumbnails?: DemoThumbnailImage[] };
const thumbnail = document.querySelector<ThumbnailDemoElement>('media-thumbnail');
if (thumbnail) {
thumbnail.thumbnails = [
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.jpg',
startTime: 0,
endTime: 10,
width: 284,
height: 160,
coords: { x: 0, y: 0 },
},
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.jpg',
startTime: 10,
endTime: 20,
width: 284,
height: 160,
coords: { x: 284, y: 0 },
},
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.jpg',
startTime: 20,
width: 284,
height: 160,
coords: { x: 568, y: 0 },
},
];
}
@@ -0,0 +1,9 @@
---
import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro';
import html from './JsonUsage.html?raw';
---
<HtmlDemo html={html} />
<script>
import './JsonUsage.ts';
</script>
@@ -0,0 +1 @@
<media-thumbnail time="12" style="max-width: 240px;"></media-thumbnail>
@@ -0,0 +1,29 @@
import '@videojs/html/ui/thumbnail';
type DemoThumbnailImage = {
url: string;
startTime: number;
endTime?: number;
};
type ThumbnailDemoElement = HTMLElement & { thumbnails?: DemoThumbnailImage[] };
const thumbnail = document.querySelector<ThumbnailDemoElement>('media-thumbnail');
if (thumbnail) {
thumbnail.thumbnails = [
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=0',
startTime: 0,
endTime: 10,
},
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=10',
startTime: 10,
endTime: 20,
},
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=20',
startTime: 20,
},
];
}
@@ -0,0 +1,23 @@
.react-thumbnail-text-track {
position: relative;
max-width: 280px;
}
.react-thumbnail-text-track__media {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.react-thumbnail-text-track__thumbnail {
display: block;
width: auto;
min-width: 0;
max-width: 240px;
}
.react-thumbnail-text-track__thumbnail[data-hidden] {
display: none;
}
@@ -0,0 +1,26 @@
import { createPlayer, Thumbnail } from '@videojs/react';
import { Video, videoFeatures } from '@videojs/react/video';
import './BasicUsage.css';
const Player = createPlayer({ features: videoFeatures });
export default function TextTrackUsage() {
return (
<Player.Provider>
<Player.Container className="react-thumbnail-text-track">
<Video
className="react-thumbnail-text-track__media"
src="https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4"
preload="auto"
muted
playsInline
crossOrigin="anonymous"
>
<track kind="metadata" label="thumbnails" src="/docs/demos/thumbnail/basic.vtt" default />
</Video>
<Thumbnail className="react-thumbnail-text-track__thumbnail" time={12} />
</Player.Container>
</Player.Provider>
);
}
@@ -0,0 +1,31 @@
import { Thumbnail } from '@videojs/react';
const THUMBNAILS = [
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.jpg',
startTime: 0,
endTime: 10,
width: 284,
height: 160,
coords: { x: 0, y: 0 },
},
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.jpg',
startTime: 10,
endTime: 20,
width: 284,
height: 160,
coords: { x: 284, y: 0 },
},
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.jpg',
startTime: 20,
width: 284,
height: 160,
coords: { x: 568, y: 0 },
},
];
export default function JsonSpriteUsage() {
return <Thumbnail thumbnails={THUMBNAILS} time={12} style={{ maxWidth: 240 }} />;
}
@@ -0,0 +1,22 @@
import { Thumbnail } from '@videojs/react';
const THUMBNAILS = [
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=0',
startTime: 0,
endTime: 10,
},
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=10',
startTime: 10,
endTime: 20,
},
{
url: 'https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/thumbnail.jpg?time=20',
startTime: 20,
},
];
export default function JsonUsage() {
return <Thumbnail thumbnails={THUMBNAILS} time={12} style={{ maxWidth: 240 }} />;
}
@@ -0,0 +1,205 @@
---
title: Thumbnail
frameworkTitle:
html: media-thumbnail
description: Time-based thumbnail preview component for timeline scrubbing and hover previews
---
import ComponentReference from "@/components/docs/api-reference/ComponentReference.astro";
import FrameworkCase from "@/components/docs/FrameworkCase.astro";
import StyleCase from "@/components/docs/StyleCase.astro";
import Demo from "@/components/docs/demos/Demo.astro";
{/* React demos */}
import TextTrackDemoReact from "@/components/docs/demos/thumbnail/react/css/BasicUsage";
import textTrackReactTsx from "@/components/docs/demos/thumbnail/react/css/BasicUsage.tsx?raw";
import textTrackReactCss from "@/components/docs/demos/thumbnail/react/css/BasicUsage.css?raw";
import JsonDemoReact from "@/components/docs/demos/thumbnail/react/css/JsonUsage";
import jsonReactTsx from "@/components/docs/demos/thumbnail/react/css/JsonUsage.tsx?raw";
import JsonSpriteDemoReact from "@/components/docs/demos/thumbnail/react/css/JsonSpriteUsage";
import jsonSpriteReactTsx from "@/components/docs/demos/thumbnail/react/css/JsonSpriteUsage.tsx?raw";
{/* HTML demos */}
import TextTrackDemoHtml from "@/components/docs/demos/thumbnail/html/css/BasicUsage.astro";
import textTrackHtml from "@/components/docs/demos/thumbnail/html/css/BasicUsage.html?raw";
import textTrackHtmlCss from "@/components/docs/demos/thumbnail/html/css/BasicUsage.css?raw";
import textTrackHtmlTs from "@/components/docs/demos/thumbnail/html/css/BasicUsage.ts?raw";
import JsonDemoHtml from "@/components/docs/demos/thumbnail/html/css/JsonUsage.astro";
import jsonHtml from "@/components/docs/demos/thumbnail/html/css/JsonUsage.html?raw";
import jsonHtmlTs from "@/components/docs/demos/thumbnail/html/css/JsonUsage.ts?raw";
import JsonSpriteDemoHtml from "@/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.astro";
import jsonSpriteHtml from "@/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.html?raw";
import jsonSpriteHtmlTs from "@/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.ts?raw";
## Quick Start: Video Track
`Thumbnail` can read thumbnail cues directly from your video track. Add a `<track>` with `kind="metadata"` and `label="thumbnails"` to your media element.
Mux provides this as `storyboard.vtt`:
`https://image.mux.com/{PLAYBACK_ID}/storyboard.vtt`
<FrameworkCase frameworks={["react"]}>
```tsx
<Video src="video.mp4">
<track
kind="metadata"
label="thumbnails"
src="https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.vtt"
default
/>
</Video>
<Thumbnail time={12} />
```
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
```html
<video src="video.mp4">
<track
kind="metadata"
label="thumbnails"
src="https://image.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/storyboard.vtt"
default
/>
</video>
<media-thumbnail time="12"></media-thumbnail>
```
</FrameworkCase>
## Anatomy
<FrameworkCase frameworks={["react"]}>
```tsx
<Thumbnail time={12} />
```
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
```html
<media-thumbnail time="12"></media-thumbnail>
```
</FrameworkCase>
## Behavior
`Thumbnail` resolves an image for the current `time`.
Supported source formats:
- Text track: `<track kind="metadata" label="thumbnails" src="...vtt">`
- JSON array: `{ url, startTime, endTime? }[]`
- JSON sprite array: `{ url, startTime, endTime?, width, height, coords }[]`
In React, text-track mode needs `Player.Provider` because it reads track state from the player store. JSON modes (`thumbnails` prop) work without `Provider`.
The component picks the latest thumbnail whose `startTime` is less than or equal to the current `time`, then scales/clips sprite tiles to fit CSS min/max constraints while preserving aspect ratio.
## Styling
Use state data attributes for pure CSS styling:
```css
media-thumbnail[data-hidden] {
display: none;
}
media-thumbnail[data-loading] {
opacity: 0.6;
}
media-thumbnail[data-error] {
outline: 1px solid #ef4444;
}
```
## Accessibility
`Thumbnail` is decorative by default (`aria-hidden="true"`). It is intended for visual preview UX (for example, timeline hover previews) rather than primary accessible content.
## Examples
### Text Track (VTT)
<FrameworkCase frameworks={["react"]}>
<StyleCase styles={["css"]}>
<Demo
files={[
{ title: "App.tsx", code: textTrackReactTsx, lang: "tsx" },
{ title: "App.css", code: textTrackReactCss, lang: "css" },
]}
>
<TextTrackDemoReact client:idle />
</Demo>
</StyleCase>
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
<StyleCase styles={["css"]}>
<Demo
files={[
{ title: "index.html", code: textTrackHtml, lang: "html" },
{ title: "index.css", code: textTrackHtmlCss, lang: "css" },
{ title: "index.ts", code: textTrackHtmlTs, lang: "ts" },
]}
>
<TextTrackDemoHtml />
</Demo>
</StyleCase>
</FrameworkCase>
### JSON Array
<FrameworkCase frameworks={["react"]}>
<StyleCase styles={["css"]}>
<Demo
files={[
{ title: "App.tsx", code: jsonReactTsx, lang: "tsx" },
]}
>
<JsonDemoReact client:idle />
</Demo>
</StyleCase>
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
<StyleCase styles={["css"]}>
<Demo
files={[
{ title: "index.html", code: jsonHtml, lang: "html" },
{ title: "index.ts", code: jsonHtmlTs, lang: "ts" },
]}
>
<JsonDemoHtml />
</Demo>
</StyleCase>
</FrameworkCase>
### JSON Sprite Array
<FrameworkCase frameworks={["react"]}>
<StyleCase styles={["css"]}>
<Demo
files={[
{ title: "App.tsx", code: jsonSpriteReactTsx, lang: "tsx" },
]}
>
<JsonSpriteDemoReact client:idle />
</Demo>
</StyleCase>
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
<StyleCase styles={["css"]}>
<Demo
files={[
{ title: "index.html", code: jsonSpriteHtml, lang: "html" },
{ title: "index.ts", code: jsonSpriteHtmlTs, lang: "ts" },
]}
>
<JsonSpriteDemoHtml />
</Demo>
</StyleCase>
</FrameworkCase>
<ComponentReference component="Thumbnail" />
+1
View File
@@ -39,6 +39,7 @@ export const sidebar: Sidebar = [
{ slug: 'reference/playback-rate-button' },
{ slug: 'reference/poster' },
{ slug: 'reference/seek-button' },
{ slug: 'reference/thumbnail' },
{ slug: 'reference/time' },
],
},