diff --git a/packages/react/src/player/context.tsx b/packages/react/src/player/context.tsx index b07d3936..06b0c164 100644 --- a/packages/react/src/player/context.tsx +++ b/packages/react/src/player/context.tsx @@ -15,6 +15,11 @@ export interface PlayerContextValue { } const PlayerContext = createContext(null); +const EMPTY_UNSUBSCRIBE = () => {}; +const EMPTY_STORE = { + state: {} as UnknownState, + subscribe: () => EMPTY_UNSUBSCRIBE, +} as Pick; export function PlayerContextProvider({ value, @@ -51,6 +56,23 @@ export function usePlayer(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(selector: (state: UnknownState) => R): R | undefined; +export function useOptionalPlayer(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(); diff --git a/packages/react/src/player/tests/context.test.tsx b/packages/react/src/player/tests/context.test.tsx index 66ae6501..5de03b04 100644 --- a/packages/react/src/player/tests/context.test.tsx +++ b/packages/react/src/player/tests/context.test.tsx @@ -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(); diff --git a/packages/react/src/ui/thumbnail/thumbnail.tsx b/packages/react/src/ui/thumbnail/thumbnail.tsx index 60a9d910..be8b3c92 100644 --- a/packages/react/src/ui/thumbnail/thumbnail.tsx +++ b/packages/react/src/ui/thumbnail/thumbnail.tsx @@ -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(function Thu const [core] = useState(() => new ThumbnailCore()); const divRef = useRef(null); const imgRef = useRef(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); diff --git a/site/public/docs/demos/thumbnail/basic.vtt b/site/public/docs/demos/thumbnail/basic.vtt new file mode 100644 index 00000000..771094e9 --- /dev/null +++ b/site/public/docs/demos/thumbnail/basic.vtt @@ -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 diff --git a/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.astro b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.astro new file mode 100644 index 00000000..e7e95f49 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.astro @@ -0,0 +1,10 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './BasicUsage.html?raw'; +import './BasicUsage.css'; +--- + + + diff --git a/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.css b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.css new file mode 100644 index 00000000..aeedebde --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.css @@ -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; +} diff --git a/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.html b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.html new file mode 100644 index 00000000..ae7a88c2 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.html @@ -0,0 +1,22 @@ +
+ + + + + + +
diff --git a/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.ts b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.ts new file mode 100644 index 00000000..93446176 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/BasicUsage.ts @@ -0,0 +1,2 @@ +import '@videojs/html/video/player'; +import '@videojs/html/ui/thumbnail'; diff --git a/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.astro b/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.astro new file mode 100644 index 00000000..b6a0c8ec --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './JsonSpriteUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.html b/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.html new file mode 100644 index 00000000..3f2a84b7 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.html @@ -0,0 +1 @@ + diff --git a/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.ts b/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.ts new file mode 100644 index 00000000..6f2d9665 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/JsonSpriteUsage.ts @@ -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('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 }, + }, + ]; +} diff --git a/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.astro b/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.astro new file mode 100644 index 00000000..dc0b9f22 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.astro @@ -0,0 +1,9 @@ +--- +import HtmlDemo from '@/components/docs/demos/HtmlDemo.astro'; +import html from './JsonUsage.html?raw'; +--- + + + diff --git a/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.html b/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.html new file mode 100644 index 00000000..3f2a84b7 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.html @@ -0,0 +1 @@ + diff --git a/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.ts b/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.ts new file mode 100644 index 00000000..eac5407f --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/html/css/JsonUsage.ts @@ -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('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, + }, + ]; +} diff --git a/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.css b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.css new file mode 100644 index 00000000..45b886db --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.css @@ -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; +} diff --git a/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx new file mode 100644 index 00000000..49f521d7 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/react/css/BasicUsage.tsx @@ -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 ( + + + + + + + ); +} diff --git a/site/src/components/docs/demos/thumbnail/react/css/JsonSpriteUsage.tsx b/site/src/components/docs/demos/thumbnail/react/css/JsonSpriteUsage.tsx new file mode 100644 index 00000000..8dfed78c --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/react/css/JsonSpriteUsage.tsx @@ -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 ; +} diff --git a/site/src/components/docs/demos/thumbnail/react/css/JsonUsage.tsx b/site/src/components/docs/demos/thumbnail/react/css/JsonUsage.tsx new file mode 100644 index 00000000..553c7dc5 --- /dev/null +++ b/site/src/components/docs/demos/thumbnail/react/css/JsonUsage.tsx @@ -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 ; +} diff --git a/site/src/content/docs/reference/thumbnail.mdx b/site/src/content/docs/reference/thumbnail.mdx new file mode 100644 index 00000000..648e7891 --- /dev/null +++ b/site/src/content/docs/reference/thumbnail.mdx @@ -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 `` with `kind="metadata"` and `label="thumbnails"` to your media element. + +Mux provides this as `storyboard.vtt`: + +`https://image.mux.com/{PLAYBACK_ID}/storyboard.vtt` + + + ```tsx + + + ``` + + + + ```html + + + ``` + + +## Anatomy + + + ```tsx + + ``` + + + + ```html + + ``` + + +## Behavior + +`Thumbnail` resolves an image for the current `time`. + +Supported source formats: + +- Text track: `` +- 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) + + + + + + + + + + + + + + + + + +### JSON Array + + + + + + + + + + + + + + + + + +### JSON Sprite Array + + + + + + + + + + + + + + + + + + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index b08d3a0e..b5be9811 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -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' }, ], },