diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx
index c1d7404f..9a058276 100644
--- a/packages/react/src/presets/video/skin.tailwind.tsx
+++ b/packages/react/src/presets/video/skin.tailwind.tsx
@@ -25,12 +25,14 @@ import {
overlay,
playbackRate,
popup,
+ poster,
preview,
root,
seek,
slider,
time,
} from '@videojs/skins/default/tailwind/video.tailwind';
+import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container, usePlayer } from '@/player/context';
@@ -43,12 +45,14 @@ import { PiPButton } from '@/ui/pip-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
+import { Poster } from '@/ui/poster';
import { SeekButton } from '@/ui/seek-button';
import { Slider } from '@/ui/slider';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { Tooltip } from '@/ui/tooltip';
import { VolumeSlider } from '@/ui/volume-slider';
+import { isRenderProp } from '@/utils/use-render';
import { ErrorDialog } from './error-dialog';
import type { VideoSkinProps } from './skin';
@@ -142,12 +146,20 @@ function FullscreenLabel(): ReactNode {
/* ------------------------------------------ Skin ------------------------------------------- */
export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
- const { children, className, ...rest } = props;
+ const { children, className, poster: posterProp, ...rest } = props;
return (
{children}
+ {posterProp && (
+
+ )}
+
(
diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx
index 8e6fc9d6..a1b491f4 100644
--- a/packages/react/src/presets/video/skin.tsx
+++ b/packages/react/src/presets/video/skin.tsx
@@ -13,6 +13,7 @@ import {
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
+import { isString } from '@videojs/utils/predicate';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container, usePlayer } from '@/player/context';
@@ -25,18 +26,20 @@ import { PiPButton } from '@/ui/pip-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
+import { Poster } from '@/ui/poster';
import { SeekButton } from '@/ui/seek-button';
import { Slider } from '@/ui/slider';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { Tooltip } from '@/ui/tooltip';
import { VolumeSlider } from '@/ui/volume-slider';
-import type { BaseSkinProps } from '../types';
+import { isRenderProp } from '@/utils/use-render';
+import type { BaseVideoSkinProps } from '../types';
import { ErrorDialog } from './error-dialog';
const SEEK_TIME = 10;
-export type VideoSkinProps = BaseSkinProps;
+export type VideoSkinProps = BaseVideoSkinProps;
const Button = forwardRef
>(function Button({ className, ...props }, ref) {
return ;
@@ -75,12 +78,16 @@ function FullscreenLabel(): ReactNode {
}
export function VideoSkin(props: VideoSkinProps): ReactNode {
- const { children, className, ...rest } = props;
+ const { children, className, poster, ...rest } = props;
return (
{children}
+ {poster && (
+
+ )}
+
(
diff --git a/packages/react/src/utils/use-render.tsx b/packages/react/src/utils/use-render.tsx
index db14ce3b..d50fcbfe 100644
--- a/packages/react/src/utils/use-render.tsx
+++ b/packages/react/src/utils/use-render.tsx
@@ -8,6 +8,11 @@ import { mergeProps } from './merge-props';
import type { HTMLProps, RenderProp } from './types';
import { composeRefs } from './use-composed-refs';
+/** Check if a value is a render prop (function or React element). */
+export function isRenderProp(value: unknown): value is RenderProp
{
+ return isFunction(value) || isValidElement(value);
+}
+
type IntrinsicTagName = keyof React.JSX.IntrinsicElements;
export interface UseRenderComponentProps {
diff --git a/packages/sandbox/app/shared/html/mux-storyboard.ts b/packages/sandbox/app/shared/html/mux-storyboard.ts
deleted file mode 100644
index 242dcc4a..00000000
--- a/packages/sandbox/app/shared/html/mux-storyboard.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { getMuxAssetId } from '../mux';
-import type { SourceId } from '../sources';
-
-export function renderMuxStoryboard(source: SourceId): string {
- const id = getMuxAssetId(source);
- return id
- ? ``
- : '';
-}
diff --git a/packages/sandbox/app/shared/html/storyboard.ts b/packages/sandbox/app/shared/html/storyboard.ts
new file mode 100644
index 00000000..e0debf8a
--- /dev/null
+++ b/packages/sandbox/app/shared/html/storyboard.ts
@@ -0,0 +1,3 @@
+export function renderStoryboard(src?: string | undefined): string {
+ return src ? `` : '';
+}
diff --git a/packages/sandbox/app/shared/mux.ts b/packages/sandbox/app/shared/mux.ts
index e4800134..6ebc75f8 100644
--- a/packages/sandbox/app/shared/mux.ts
+++ b/packages/sandbox/app/shared/mux.ts
@@ -3,8 +3,3 @@ import { SOURCES, type SourceId } from './sources';
export function getMuxAssetId(source: SourceId): string | undefined {
return SOURCES[source].url.match(/stream\.mux\.com\/([a-zA-Z0-9]+)/)?.[1];
}
-
-export function getMuxPosterSrc(source: SourceId): string | undefined {
- const id = getMuxAssetId(source);
- return id ? `https://image.mux.com/${id}/thumbnail.jpg` : undefined;
-}
diff --git a/packages/sandbox/app/shared/react/mux-poster.tsx b/packages/sandbox/app/shared/react/mux-poster.tsx
deleted file mode 100644
index ad8fb79c..00000000
--- a/packages/sandbox/app/shared/react/mux-poster.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Poster } from '@videojs/react';
-
-import { getMuxPosterSrc } from '../mux';
-import type { SourceId } from '../sources';
-
-type MuxPosterProps = {
- source: SourceId;
-};
-
-export function MuxPoster({ source }: MuxPosterProps) {
- const src = getMuxPosterSrc(source);
- if (!src) return null;
- return ;
-}
diff --git a/packages/sandbox/app/shared/react/mux-storyboard.tsx b/packages/sandbox/app/shared/react/mux-storyboard.tsx
deleted file mode 100644
index a075c0b6..00000000
--- a/packages/sandbox/app/shared/react/mux-storyboard.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { getMuxAssetId } from '../mux';
-import type { SourceId } from '../sources';
-
-type MuxStoryboardProps = {
- source: SourceId;
-};
-
-export function MuxStoryboard({ source }: MuxStoryboardProps) {
- const id = getMuxAssetId(source);
- if (!id) return null;
- return ;
-}
diff --git a/packages/sandbox/app/shared/react/storyboard.tsx b/packages/sandbox/app/shared/react/storyboard.tsx
new file mode 100644
index 00000000..2cb024ff
--- /dev/null
+++ b/packages/sandbox/app/shared/react/storyboard.tsx
@@ -0,0 +1,8 @@
+type StoryboardProps = {
+ src?: string | undefined;
+};
+
+export function Storyboard({ src }: StoryboardProps) {
+ if (!src) return null;
+ return ;
+}
diff --git a/packages/sandbox/app/shared/react/use-poster.ts b/packages/sandbox/app/shared/react/use-poster.ts
new file mode 100644
index 00000000..8631a190
--- /dev/null
+++ b/packages/sandbox/app/shared/react/use-poster.ts
@@ -0,0 +1,8 @@
+import { useMemo } from 'react';
+import { getPosterSrc } from '../sources';
+import { useSource } from './use-source';
+
+export function usePoster() {
+ const source = useSource();
+ return useMemo(() => getPosterSrc(source), [source]);
+}
diff --git a/packages/sandbox/app/shared/react/use-storyboard.ts b/packages/sandbox/app/shared/react/use-storyboard.ts
new file mode 100644
index 00000000..bef0963c
--- /dev/null
+++ b/packages/sandbox/app/shared/react/use-storyboard.ts
@@ -0,0 +1,8 @@
+import { useMemo } from 'react';
+import { getStoryboardSrc } from '../sources';
+import { useSource } from './use-source';
+
+export function useStoryboard() {
+ const source = useSource();
+ return useMemo(() => getStoryboardSrc(source), [source]);
+}
diff --git a/packages/sandbox/app/shared/sources.ts b/packages/sandbox/app/shared/sources.ts
index cafe7478..8800bfe6 100644
--- a/packages/sandbox/app/shared/sources.ts
+++ b/packages/sandbox/app/shared/sources.ts
@@ -1,3 +1,5 @@
+import { getMuxAssetId } from './mux';
+
export const SOURCES = {
'hls-1': {
label: 'HLS - Big Buck Bunny',
@@ -6,7 +8,7 @@ export const SOURCES = {
subType: 'ts',
},
'hls-2': {
- label: 'HLS - 2',
+ label: 'HLS - Elephants Dream',
url: 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8',
type: 'hls',
subType: 'ts',
@@ -56,3 +58,13 @@ export const DEFAULT_AUDIO_SOURCE: SourceId = 'mp4-1';
export const DEFAULT_DASH_SOURCE: SourceId = 'dash-1';
export const BACKGROUND_VIDEO_SRC = 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008/low.mp4';
+
+export function getPosterSrc(source: SourceId): string | undefined {
+ const id = getMuxAssetId(source);
+ return id ? `https://image.mux.com/${id}/thumbnail.jpg` : undefined;
+}
+
+export function getStoryboardSrc(source: SourceId): string | undefined {
+ const id = getMuxAssetId(source);
+ return id ? `https://image.mux.com/${id}/storyboard.vtt` : undefined;
+}
diff --git a/packages/sandbox/scripts/reset.ts b/packages/sandbox/scripts/reset.ts
index f0fb27f7..cff0e31f 100644
--- a/packages/sandbox/scripts/reset.ts
+++ b/packages/sandbox/scripts/reset.ts
@@ -1,7 +1,16 @@
import chalk from 'chalk';
import fs from 'fs-extra';
-import { confirm, filePath, getChanges, printDiff, srcPath, templatesPath } from './shared.js';
+import {
+ confirm,
+ filePath,
+ getChanges,
+ mirrorTemplatesToSrc,
+ printDiff,
+ removeGeneratedSrcFiles,
+ srcPath,
+ templatesPath,
+} from './shared.js';
const changes = getChanges();
@@ -9,25 +18,39 @@ const changes = getChanges();
const modified = changes.filter((d) => d.state === 'distinct');
// Files only in src (will be deleted)
const added = changes.filter((d) => d.state === 'left');
+// Files only in templates (will be recreated in src)
+const missing = changes.filter((d) => d.state === 'right');
-if (modified.length === 0 && added.length === 0) {
+if (modified.length === 0 && added.length === 0 && missing.length === 0) {
console.log(chalk.gray('\nNo local changes. Already in sync with templates.\n'));
process.exit(0);
}
-console.log(chalk.bold('\nThe following local changes will be lost:\n'));
+if (modified.length > 0 || added.length > 0) {
+ console.log(chalk.bold('\nThe following changes in src/ will be replaced from templates/:\n'));
+}
for (const change of modified) {
const label = filePath(change);
console.log(chalk.yellow(` ~ ${label}`));
- printDiff(templatesPath(change), srcPath(change), label);
+ printDiff(srcPath(change), templatesPath(change), label);
}
for (const change of added) {
console.log(chalk.red(` + ${filePath(change)} (will be deleted)`));
}
-console.log();
+if (missing.length > 0) {
+ console.log(chalk.bold('\nThe following template files will be restored into src/:\n'));
+
+ for (const change of missing) {
+ console.log(chalk.green(` + ${filePath(change)} (will be restored)`));
+ }
+}
+
+if (modified.length > 0 || added.length > 0 || missing.length > 0) {
+ console.log();
+}
const ok = await confirm('Reset src/ to templates? This cannot be undone.');
@@ -46,4 +69,15 @@ for (const change of added) {
console.log(chalk.red(` ✔ Deleted ${filePath(change)}`));
}
+const restored = await mirrorTemplatesToSrc();
+const removedGenerated = await removeGeneratedSrcFiles();
+
+for (const file of restored) {
+ console.log(chalk.green(` ✔ Restored ${file.replace(/^src\//, '')}`));
+}
+
+for (const file of removedGenerated) {
+ console.log(chalk.green(` ✔ Removed generated ${file.replace(/^src\//, '')}`));
+}
+
console.log(chalk.bold.green('\nReset complete!\n'));
diff --git a/packages/sandbox/scripts/setup.ts b/packages/sandbox/scripts/setup.ts
index 8ee48df2..2906b3f0 100644
--- a/packages/sandbox/scripts/setup.ts
+++ b/packages/sandbox/scripts/setup.ts
@@ -1,27 +1,12 @@
-import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
-import { resolve } from 'node:path';
+import { mirrorTemplatesToSrc, removeGeneratedSrcFiles } from './shared.js';
-const root = resolve(import.meta.dirname, '..');
-const templatesDir = resolve(root, 'templates');
-const srcDir = resolve(root, 'src');
+const created = await mirrorTemplatesToSrc();
+const removed = await removeGeneratedSrcFiles();
-/** Recursively copy template files into `src/`, preserving relative paths. */
-function mirror(dir: string) {
- for (const entry of readdirSync(dir)) {
- const templatePath = resolve(dir, entry);
- const targetPath = resolve(srcDir, templatePath.slice(templatesDir.length + 1));
-
- if (statSync(templatePath).isDirectory()) {
- mkdirSync(targetPath, { recursive: true });
- mirror(templatePath);
- continue;
- }
-
- if (!existsSync(targetPath)) {
- copyFileSync(templatePath, targetPath);
- console.log(`Created ${targetPath.slice(root.length + 1)}`);
- }
- }
+for (const file of created) {
+ console.log(`Created ${file}`);
}
-mirror(templatesDir);
+for (const file of removed) {
+ console.log(`Removed generated ${file}`);
+}
diff --git a/packages/sandbox/scripts/shared.ts b/packages/sandbox/scripts/shared.ts
index d76b6bb0..e926792e 100644
--- a/packages/sandbox/scripts/shared.ts
+++ b/packages/sandbox/scripts/shared.ts
@@ -8,6 +8,8 @@ import prompts from 'prompts';
export const SRC = './src';
export const TEMPLATES = './templates';
+const IGNORED_ROOT_FILES = new Set(['index.html']);
+const GENERATED_SRC_FILES = new Set(['index.html', '__app-shell__.ts']);
export interface Change {
state: 'left' | 'right' | 'distinct';
@@ -25,7 +27,7 @@ export function getChanges(): Change[] {
name: (d.name1 ?? d.name2)!,
relativePath: d.relativePath,
}))
- .filter((d) => d.name != null);
+ .filter((d) => d.name != null && !shouldIgnoreChange(d));
}
function colorizePatch(patch: string): string {
@@ -53,6 +55,21 @@ export function printDiff(oldPath: string, newPath: string, label: string): void
console.log(colorizePatch(body));
}
+function isGeneratedSrcFile(change: Change): boolean {
+ return change.relativePath === '.' && GENERATED_SRC_FILES.has(change.name);
+}
+
+function isIgnoredRootFile(change: Change): boolean {
+ return change.relativePath === '.' && IGNORED_ROOT_FILES.has(change.name);
+}
+
+function shouldIgnoreChange(change: Change): boolean {
+ if (isGeneratedSrcFile(change) || isIgnoredRootFile(change)) return true;
+
+ // Sync/reset only manage sandbox directories, not loose files at the root.
+ return change.relativePath === '.';
+}
+
export function filePath(change: Change): string {
return path.join(change.relativePath, change.name);
}
@@ -65,6 +82,57 @@ export function templatesPath(change: Change): string {
return path.join(TEMPLATES, change.relativePath, change.name);
}
+export async function mirrorTemplatesToSrc(): Promise {
+ const created: string[] = [];
+
+ async function mirror(dir: string): Promise {
+ for (const entry of await fs.readdir(dir)) {
+ const templatePath = path.join(dir, entry);
+ const relativeFilePath = path.relative(TEMPLATES, templatePath);
+ const targetPath = path.join(SRC, relativeFilePath);
+ const stats = await fs.stat(templatePath);
+
+ if (stats.isDirectory()) {
+ await fs.ensureDir(targetPath);
+ await mirror(templatePath);
+ continue;
+ }
+
+ if (await fs.pathExists(targetPath)) continue;
+
+ await fs.copy(templatePath, targetPath);
+ created.push(targetPath);
+ }
+ }
+
+ for (const entry of await fs.readdir(TEMPLATES)) {
+ const templatePath = path.join(TEMPLATES, entry);
+ const stats = await fs.stat(templatePath);
+
+ if (!stats.isDirectory()) continue;
+
+ await fs.ensureDir(path.join(SRC, entry));
+ await mirror(templatePath);
+ }
+
+ return created;
+}
+
+export async function removeGeneratedSrcFiles(): Promise {
+ const removed: string[] = [];
+
+ for (const file of GENERATED_SRC_FILES) {
+ const filePath = path.join(SRC, file);
+
+ if (!(await fs.pathExists(filePath))) continue;
+
+ await fs.remove(filePath);
+ removed.push(filePath);
+ }
+
+ return removed;
+}
+
export async function confirm(message: string): Promise {
const { ok } = await prompts({
type: 'confirm',
diff --git a/packages/sandbox/templates/html-dash-video/main.ts b/packages/sandbox/templates/html-dash-video/main.ts
index 9288a51f..24bf7ed1 100644
--- a/packages/sandbox/templates/html-dash-video/main.ts
+++ b/packages/sandbox/templates/html-dash-video/main.ts
@@ -3,8 +3,9 @@ import '@videojs/html/video/player';
import '@videojs/html/media/dash-video';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
+import { renderStoryboard } from '@app/shared/html/storyboard';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
-import { SOURCES } from '@app/shared/sources';
+import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
const html = String.raw;
@@ -15,10 +16,16 @@ async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;
+ const storyboard = getStoryboardSrc(state.source);
+ const poster = getPosterSrc(state.source);
+
document.getElementById('root')!.innerHTML = html`
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
-
+
+ ${renderStoryboard(storyboard)}
+
+ ${poster ? html`
` : ''}
${tag}>
`;
diff --git a/packages/sandbox/templates/html-hls-video/main.ts b/packages/sandbox/templates/html-hls-video/main.ts
index 5722fa17..db5c4cb7 100644
--- a/packages/sandbox/templates/html-hls-video/main.ts
+++ b/packages/sandbox/templates/html-hls-video/main.ts
@@ -1,11 +1,11 @@
import '@app/styles.css';
import '@videojs/html/video/player';
import '@videojs/html/media/hls-video';
-import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
+import { renderStoryboard } from '@app/shared/html/storyboard';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
-import { SOURCES } from '@app/shared/sources';
+import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
const html = String.raw;
@@ -16,12 +16,16 @@ async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;
+ const storyboard = getStoryboardSrc(state.source);
+ const poster = getPosterSrc(state.source);
+
document.getElementById('root')!.innerHTML = html`
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
- ${renderMuxStoryboard(state.source)}
+ ${renderStoryboard(storyboard)}
+ ${poster ? html`
` : ''}
${tag}>
`;
diff --git a/packages/sandbox/templates/html-simple-hls-video/main.ts b/packages/sandbox/templates/html-simple-hls-video/main.ts
index 907a746c..ed3687aa 100644
--- a/packages/sandbox/templates/html-simple-hls-video/main.ts
+++ b/packages/sandbox/templates/html-simple-hls-video/main.ts
@@ -1,11 +1,11 @@
import '@app/styles.css';
import '@videojs/html/video/player';
import '@videojs/html/media/simple-hls-video';
-import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
+import { renderStoryboard } from '@app/shared/html/storyboard';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
-import { SOURCES } from '@app/shared/sources';
+import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
const html = String.raw;
@@ -16,12 +16,16 @@ async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;
+ const storyboard = getStoryboardSrc(state.source);
+ const poster = getPosterSrc(state.source);
+
document.getElementById('root')!.innerHTML = html`
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
- ${renderMuxStoryboard(state.source)}
+ ${renderStoryboard(storyboard)}
+ ${poster ? html`
` : ''}
${tag}>
`;
diff --git a/packages/sandbox/templates/html-video/main.ts b/packages/sandbox/templates/html-video/main.ts
index 6bfd3d05..95b01ecf 100644
--- a/packages/sandbox/templates/html-video/main.ts
+++ b/packages/sandbox/templates/html-video/main.ts
@@ -1,11 +1,11 @@
import '@app/styles.css';
import '@videojs/html/video/player';
import '@videojs/html/ui/poster';
-import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
-import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
-import { SOURCES } from '@app/shared/sources';
+import { renderStoryboard } from '@app/shared/html/storyboard';
+import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
+import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
const html = String.raw;
@@ -16,12 +16,16 @@ async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;
+ const storyboard = getStoryboardSrc(state.source);
+ const poster = getPosterSrc(state.source);
+
document.getElementById('root')!.innerHTML = html`
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
+ ${poster ? html`
` : ''}
${tag}>
`;
diff --git a/packages/sandbox/templates/react-hls-video/main.tsx b/packages/sandbox/templates/react-hls-video/main.tsx
index 97ccbc8d..87f5ab6d 100644
--- a/packages/sandbox/templates/react-hls-video/main.tsx
+++ b/packages/sandbox/templates/react-hls-video/main.tsx
@@ -1,10 +1,11 @@
import '@app/styles.css';
-import { MuxPoster } from '@app/shared/react/mux-poster';
-import { MuxStoryboard } from '@app/shared/react/mux-storyboard';
import { VideoProvider } from '@app/shared/react/providers';
import { VideoSkinComponent } from '@app/shared/react/skins';
+import { Storyboard } from '@app/shared/react/storyboard';
+import { usePoster } from '@app/shared/react/use-poster';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
+import { useStoryboard } from '@app/shared/react/use-storyboard';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { HlsVideo } from '@videojs/react/media/hls-video';
@@ -19,14 +20,20 @@ function App() {
const skin = useSkin();
const source = useSource();
const styling = useMemo(readStyling, []);
+ const poster = usePoster();
+ const storyboard = useStoryboard();
return (
-
+
-
+
-
);
diff --git a/packages/sandbox/templates/react-simple-hls-video/main.tsx b/packages/sandbox/templates/react-simple-hls-video/main.tsx
index c2a9aa59..3567639a 100644
--- a/packages/sandbox/templates/react-simple-hls-video/main.tsx
+++ b/packages/sandbox/templates/react-simple-hls-video/main.tsx
@@ -1,10 +1,11 @@
import '@app/styles.css';
-import { MuxPoster } from '@app/shared/react/mux-poster';
-import { MuxStoryboard } from '@app/shared/react/mux-storyboard';
import { VideoProvider } from '@app/shared/react/providers';
import { VideoSkinComponent } from '@app/shared/react/skins';
+import { Storyboard } from '@app/shared/react/storyboard';
+import { usePoster } from '@app/shared/react/use-poster';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
+import { useStoryboard } from '@app/shared/react/use-storyboard';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { SimpleHlsVideo } from '@videojs/react/media/simple-hls-video';
@@ -19,14 +20,20 @@ function App() {
const skin = useSkin();
const source = useSource();
const styling = useMemo(readStyling, []);
+ const poster = usePoster();
+ const storyboard = useStoryboard();
return (
-
+
-
+
-
);
diff --git a/packages/sandbox/templates/react-video/main.tsx b/packages/sandbox/templates/react-video/main.tsx
index 36618647..124db892 100644
--- a/packages/sandbox/templates/react-video/main.tsx
+++ b/packages/sandbox/templates/react-video/main.tsx
@@ -1,10 +1,11 @@
import '@app/styles.css';
-import { MuxPoster } from '@app/shared/react/mux-poster';
-import { MuxStoryboard } from '@app/shared/react/mux-storyboard';
import { VideoProvider } from '@app/shared/react/providers';
import { VideoSkinComponent } from '@app/shared/react/skins';
+import { Storyboard } from '@app/shared/react/storyboard';
+import { usePoster } from '@app/shared/react/use-poster';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
+import { useStoryboard } from '@app/shared/react/use-storyboard';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { Video } from '@videojs/react/video';
@@ -19,14 +20,20 @@ function App() {
const skin = useSkin();
const source = useSource();
const styling = useMemo(readStyling, []);
+ const poster = usePoster();
+ const storyboard = useStoryboard();
return (
-
+
-
);
diff --git a/packages/sandbox/vite.config.ts b/packages/sandbox/vite.config.ts
index e5413f56..45fcffef 100644
--- a/packages/sandbox/vite.config.ts
+++ b/packages/sandbox/vite.config.ts
@@ -22,11 +22,6 @@ function getSandboxEntries(): Record {
return entries;
}
-/**
- * Serve app/index.html as the shell entry.
- * - Dev: middleware intercepts `/` and serves the shell HTML.
- * - Build: temporarily copies to `src/index.html` so Rollup can find it within root.
- */
function serveAppShell(): Plugin {
const shellSrc = resolve(__dirname, 'app/index.html');
const shellEntry = normalizePath(resolve(__dirname, 'app/main.tsx'));
@@ -35,7 +30,6 @@ function serveAppShell(): Plugin {
return {
name: 'serve-app-shell',
buildStart() {
- // Rewrite relative paths to point to app/ since the copy lives in src/
const html = readFileSync(shellSrc, 'utf-8').replace(/(src|href)="\.\/([^"]+)"/g, '$1="../app/$2"');
writeFileSync(shellDest, html);
},
@@ -43,19 +37,21 @@ function serveAppShell(): Plugin {
rmSync(shellDest, { force: true });
},
configureServer(server) {
- return () => {
- server.middlewares.use(async (req, res, next) => {
- if (req.url === '/' || req.url === '/index.html') {
- const html = readFileSync(shellSrc, 'utf-8').replace('./main.tsx', `/@fs/${shellEntry}`);
- const transformed = await server.transformIndexHtml('/app/index.html', html, req.originalUrl);
- res.setHeader('Content-Type', 'text/html');
- res.end(transformed);
- return;
- }
+ server.middlewares.use(async (req, res, next) => {
+ const requestUrl = req.originalUrl ?? req.url ?? '/';
+ const { pathname } = new URL(requestUrl, 'http://localhost');
- next();
- });
- };
+ if (pathname === '/' || pathname === '/index.html') {
+ const html = readFileSync(shellSrc, 'utf-8').replace('./main.tsx', `/@fs/${shellEntry}`);
+ const transformed = await server.transformIndexHtml('/app/index.html', html, requestUrl);
+
+ res.setHeader('Content-Type', 'text/html');
+ res.end(transformed);
+ return;
+ }
+
+ next();
+ });
},
};
}
diff --git a/packages/skins/src/default/css/components/media.css b/packages/skins/src/default/css/components/media.css
index 72d28b4f..3a236e34 100644
--- a/packages/skins/src/default/css/components/media.css
+++ b/packages/skins/src/default/css/components/media.css
@@ -17,30 +17,6 @@
border-radius: inherit;
}
-/* ==========================================================================
- Poster Image
- ========================================================================== */
-
-.media-default-skin > img {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- object-fit: var(--media-object-fit, contain);
- object-position: var(--media-object-position, center);
- transition: opacity 0.25s;
- pointer-events: none;
- border-radius: inherit;
-
- &:not([data-visible]) {
- opacity: 0;
- }
-}
-
-/* ==========================================================================
- Fullscreen
- ========================================================================== */
-
.media-default-skin:fullscreen ::slotted(video),
.media-default-skin:fullscreen video {
object-fit: contain;
diff --git a/packages/skins/src/default/css/components/poster.css b/packages/skins/src/default/css/components/poster.css
new file mode 100644
index 00000000..7153fa4f
--- /dev/null
+++ b/packages/skins/src/default/css/components/poster.css
@@ -0,0 +1,34 @@
+/* ==========================================================================
+ Poster Image
+ ========================================================================== */
+
+.media-default-skin media-poster,
+.media-default-skin > img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ transition: opacity 0.25s;
+ pointer-events: none;
+}
+.media-default-skin media-poster:not([data-visible]),
+.media-default-skin > img:not([data-visible]) {
+ opacity: 0;
+}
+.media-default-skin media-poster ::slotted(img) {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: var(--media-object-fit, contain);
+ object-position: var(--media-object-position, center);
+ border-radius: var(--media-video-border-radius);
+}
+.media-default-skin > img {
+ border-radius: inherit;
+}
+
+.media-default-skin:fullscreen media-poster ::slotted(img),
+.media-default-skin:fullscreen > img {
+ object-fit: contain;
+}
diff --git a/packages/skins/src/default/css/video.css b/packages/skins/src/default/css/video.css
index 0959d426..e71d8ce0 100644
--- a/packages/skins/src/default/css/video.css
+++ b/packages/skins/src/default/css/video.css
@@ -11,6 +11,7 @@
@import "./components/time.css";
@import "./components/buttons.css";
@import "./components/icons.css";
+@import "./components/poster.css";
@import "./components/preview.css";
@import "./components/slider.css";
@import "./components/popup.css";
@@ -136,6 +137,7 @@
filter: blur(8px);
transition-property: scale, opacity, filter;
transition-duration: 150ms;
+ transition-timing-function: ease-out;
transform-origin: bottom;
pointer-events: none;
diff --git a/packages/skins/src/default/tailwind/components/poster.ts b/packages/skins/src/default/tailwind/components/poster.ts
new file mode 100644
index 00000000..4aea9b12
--- /dev/null
+++ b/packages/skins/src/default/tailwind/components/poster.ts
@@ -0,0 +1,21 @@
+import { cn } from '@videojs/utils/style';
+
+export const poster = (isShadowDOM: boolean) =>
+ cn(
+ 'absolute inset-0 w-full h-full pointer-events-none',
+ // Fade in/out with the `data-visible` attribute
+ 'transition-opacity duration-250',
+ 'not-data-visible:opacity-0',
+ // In the shadow DOM, the class applies to the parent so we have to set styles on the slotted img.
+ isShadowDOM
+ ? [
+ '[&_::slotted(img)]:absolute',
+ '[&_::slotted(img)]:inset-0',
+ '[&_::slotted(img)]:w-full',
+ '[&_::slotted(img)]:h-full',
+ '[&_::slotted(img)]:[object-fit:var(--media-object-fit,contain)]',
+ '[&_::slotted(img)]:[object-position:var(--media-object-position,center)]',
+ '[&_::slotted(img)]:rounded-(--media-video-border-radius)',
+ ]
+ : 'rounded-[inherit] [object-fit:var(--media-object-fit,contain)] [object-position:var(--media-object-position,center)]'
+ );
diff --git a/packages/skins/src/default/tailwind/video.tailwind.ts b/packages/skins/src/default/tailwind/video.tailwind.ts
index 11c67993..493b7b34 100644
--- a/packages/skins/src/default/tailwind/video.tailwind.ts
+++ b/packages/skins/src/default/tailwind/video.tailwind.ts
@@ -33,11 +33,6 @@ export const root = (isShadowDOM: boolean) =>
'[@media(pointer:fine)]:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]',
'[@media(pointer:coarse)]:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]',
'motion-reduce:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:100ms]',
- // Poster image
- '[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full [&>img]:rounded-[inherit]',
- '[&>img]:[object-fit:var(--media-object-fit,contain)] [&>img]:[object-position:var(--media-object-position,center)] [&>img]:pointer-events-none',
- '[&>img]:transition-opacity [&>img]:duration-250',
- '[&>img:not([data-visible])]:opacity-0',
// Caption track CSS variables (consumed by the native caption bridge in light DOM)
'[--media-caption-track-y:-0.5rem]',
'[--media-caption-track-delay:calc(var(--media-controls-transition-delay)_+_25ms)]',
@@ -171,5 +166,6 @@ export { button } from './components/button';
export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon';
export { overlay } from './components/overlay';
export { playbackRate } from './components/playback-rate';
+export { poster } from './components/poster';
export { seek } from './components/seek';
export { time } from './components/time';
diff --git a/packages/skins/src/minimal/css/components/poster.css b/packages/skins/src/minimal/css/components/poster.css
new file mode 100644
index 00000000..38a5dcc5
--- /dev/null
+++ b/packages/skins/src/minimal/css/components/poster.css
@@ -0,0 +1,34 @@
+/* ==========================================================================
+ Poster Image
+ ========================================================================== */
+
+.media-minimal-skin media-poster,
+.media-minimal-skin > img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ transition: opacity 0.25s;
+ pointer-events: none;
+}
+.media-minimal-skin media-poster:not([data-visible]),
+.media-minimal-skin > img:not([data-visible]) {
+ opacity: 0;
+}
+.media-minimal-skin media-poster ::slotted(img) {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: var(--media-object-fit, contain);
+ object-position: var(--media-object-position, center);
+ border-radius: var(--media-video-border-radius);
+}
+.media-minimal-skin > img {
+ border-radius: inherit;
+}
+
+.media-minimal-skin:fullscreen media-poster ::slotted(img),
+.media-minimal-skin:fullscreen > img {
+ object-fit: contain;
+}
diff --git a/packages/skins/src/minimal/css/video.css b/packages/skins/src/minimal/css/video.css
index 44a29737..9a420f94 100644
--- a/packages/skins/src/minimal/css/video.css
+++ b/packages/skins/src/minimal/css/video.css
@@ -10,6 +10,7 @@
@import "./components/time.css";
@import "./components/buttons.css";
@import "./components/icons.css";
+@import "./components/poster.css";
@import "./components/preview.css";
@import "./components/slider.css";
@import "./components/popup.css";
@@ -146,6 +147,7 @@
filter: blur(8px);
transition-property: scale, opacity, filter;
transition-duration: 150ms;
+ transition-timing-function: ease-out;
transform-origin: bottom;
& .media-preview__thumbnail-wrapper {
diff --git a/packages/skins/src/minimal/tailwind/components/playback-rate.ts b/packages/skins/src/minimal/tailwind/components/playback-rate.ts
new file mode 100644
index 00000000..5040a887
--- /dev/null
+++ b/packages/skins/src/minimal/tailwind/components/playback-rate.ts
@@ -0,0 +1,3 @@
+export const playbackRate = {
+ button: `after:content-[attr(data-rate)_'×'] after:w-[4ch] after:tabular-nums`,
+};
diff --git a/packages/skins/src/minimal/tailwind/components/poster.ts b/packages/skins/src/minimal/tailwind/components/poster.ts
new file mode 100644
index 00000000..4aea9b12
--- /dev/null
+++ b/packages/skins/src/minimal/tailwind/components/poster.ts
@@ -0,0 +1,21 @@
+import { cn } from '@videojs/utils/style';
+
+export const poster = (isShadowDOM: boolean) =>
+ cn(
+ 'absolute inset-0 w-full h-full pointer-events-none',
+ // Fade in/out with the `data-visible` attribute
+ 'transition-opacity duration-250',
+ 'not-data-visible:opacity-0',
+ // In the shadow DOM, the class applies to the parent so we have to set styles on the slotted img.
+ isShadowDOM
+ ? [
+ '[&_::slotted(img)]:absolute',
+ '[&_::slotted(img)]:inset-0',
+ '[&_::slotted(img)]:w-full',
+ '[&_::slotted(img)]:h-full',
+ '[&_::slotted(img)]:[object-fit:var(--media-object-fit,contain)]',
+ '[&_::slotted(img)]:[object-position:var(--media-object-position,center)]',
+ '[&_::slotted(img)]:rounded-(--media-video-border-radius)',
+ ]
+ : 'rounded-[inherit] [object-fit:var(--media-object-fit,contain)] [object-position:var(--media-object-position,center)]'
+ );
diff --git a/packages/skins/src/minimal/tailwind/video.tailwind.ts b/packages/skins/src/minimal/tailwind/video.tailwind.ts
index 0e592d44..9ee13ec6 100644
--- a/packages/skins/src/minimal/tailwind/video.tailwind.ts
+++ b/packages/skins/src/minimal/tailwind/video.tailwind.ts
@@ -30,11 +30,6 @@ export const root = (isShadowDOM: boolean) =>
'[@media(pointer:fine)]:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:300ms]',
'[@media(pointer:coarse)]:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:150ms]',
'motion-reduce:has-[[data-controls]:not([data-visible])]:[--media-controls-transition-duration:100ms]',
- // Poster image
- '[&>img]:absolute [&>img]:inset-0 [&>img]:w-full [&>img]:h-full [&>img]:rounded-[inherit]',
- '[&>img]:[object-fit:var(--media-object-fit,contain)] [&>img]:[object-position:var(--media-object-position,center)] [&>img]:pointer-events-none',
- '[&>img]:transition-opacity [&>img]:duration-250',
- '[&>img:not([data-visible])]:opacity-0',
// Caption track CSS variables (consumed by the native caption bridge in light DOM)
'[--media-caption-track-y:-0.5rem]',
'[--media-caption-track-delay:calc(var(--media-controls-transition-delay)_+_25ms)]',
@@ -140,5 +135,7 @@ export { buttonGroup } from './components/button-group';
export { error } from './components/error';
export { icon, iconContainer, iconFlipped, iconHidden } from './components/icon';
export { overlay } from './components/overlay';
+export { playbackRate } from './components/playback-rate';
+export { poster } from './components/poster';
export { seek } from './components/seek';
export { time } from './components/time';
diff --git a/site/src/components/home/Demo/Base.tsx b/site/src/components/home/Demo/Base.tsx
index 5bade528..4f78f9e3 100644
--- a/site/src/components/home/Demo/Base.tsx
+++ b/site/src/components/home/Demo/Base.tsx
@@ -25,7 +25,7 @@ function generateReactCode(skin: Skin): string {
const skinComponent = skin === 'default' ? 'VideoSkin' : 'MinimalVideoSkin';
const skinCss = skin === 'default' ? 'skin' : 'minimal-skin';
- return `import { createPlayer, Poster } from '@videojs/react';
+ return `import { createPlayer } from '@videojs/react';
import { ${skinComponent}, Video, videoFeatures } from '@videojs/react/video';
import '@videojs/react/video/${skinCss}.css';
@@ -34,9 +34,8 @@ const Player = createPlayer({ features: videoFeatures });
export function VideoPlayer() {
return (
- <${skinComponent}>
+ <${skinComponent} poster={VJS10_DEMO_VIDEO.poster}>
-
${skinComponent}>
);
diff --git a/site/src/components/home/HeroVideo.tsx b/site/src/components/home/HeroVideo.tsx
index c6a1a156..10c8878a 100644
--- a/site/src/components/home/HeroVideo.tsx
+++ b/site/src/components/home/HeroVideo.tsx
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react';
-import { createPlayer, Poster } from '@videojs/react';
+import { createPlayer } from '@videojs/react';
import { HlsVideo } from '@videojs/react/media/hls-video';
import { MinimalVideoSkin, VideoSkin, videoFeatures } from '@videojs/react/video';
import { VJS10_DEMO_VIDEO } from '@/consts';
@@ -32,9 +32,9 @@ export default function HeroVideo({
...style,
} as React.CSSProperties
}
+ poster={poster}
>
-
);
diff --git a/site/src/examples/react/FrostedSkin/FrostedSkinDemo.tsx b/site/src/examples/react/FrostedSkin/FrostedSkinDemo.tsx
index 4e54e092..6c7ad9af 100644
--- a/site/src/examples/react/FrostedSkin/FrostedSkinDemo.tsx
+++ b/site/src/examples/react/FrostedSkin/FrostedSkinDemo.tsx
@@ -1,6 +1,6 @@
import { VJS10_DEMO_VIDEO } from '@/consts';
-import { createPlayer, Poster } from '@videojs/react';
+import { createPlayer } from '@videojs/react';
import { VideoSkin, Video, videoFeatures } from '@videojs/react/video';
import '@videojs/react/video/skin.css';
@@ -19,9 +19,8 @@ const Player = createPlayer({ features: videoFeatures });
export function FrostedSkinDemo() {
return (
-
+
-
);
diff --git a/site/src/examples/react/MinimalSkin/MinimalSkinDemo.tsx b/site/src/examples/react/MinimalSkin/MinimalSkinDemo.tsx
index eb13e04a..4637a2d6 100644
--- a/site/src/examples/react/MinimalSkin/MinimalSkinDemo.tsx
+++ b/site/src/examples/react/MinimalSkin/MinimalSkinDemo.tsx
@@ -1,5 +1,5 @@
import { VJS10_DEMO_VIDEO } from '@/consts';
-import { createPlayer, Poster } from '@videojs/react';
+import { createPlayer } from '@videojs/react';
import { MinimalVideoSkin, Video, videoFeatures } from '@videojs/react/video';
import '@videojs/react/video/minimal-skin.css';
@@ -17,9 +17,8 @@ const Player = createPlayer({ features: videoFeatures });
export function MinimalSkinDemo() {
return (
-
+
-
);