diff --git a/packages/sandbox/.gitignore b/packages/sandbox/.gitignore
index c16e81c1..e71bfe66 100644
--- a/packages/sandbox/.gitignore
+++ b/packages/sandbox/.gitignore
@@ -1,2 +1,3 @@
src/*
!src/index.html
+!src/main.tsx
diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json
index 29a1a666..6d2c237a 100644
--- a/packages/sandbox/package.json
+++ b/packages/sandbox/package.json
@@ -16,7 +16,8 @@
"@videojs/react": "workspace:*",
"@videojs/skins": "workspace:*",
"@videojs/store": "workspace:*",
- "@videojs/utils": "workspace:*"
+ "@videojs/utils": "workspace:*",
+ "wouter": "^3.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.0",
diff --git a/packages/sandbox/scripts/sync.ts b/packages/sandbox/scripts/sync.ts
index ab7df404..aa49f47c 100644
--- a/packages/sandbox/scripts/sync.ts
+++ b/packages/sandbox/scripts/sync.ts
@@ -7,6 +7,8 @@ import prompts from 'prompts';
const SOURCE = './src';
const TEMPLATES = './templates';
+// Some file always live in src
+const IGNORE = new Set(['index.html', 'main.tsx']);
function colorizePatch(patch: string): string {
return patch
@@ -38,7 +40,12 @@ console.log(chalk.bold(`\nComparing ${SOURCE} → ${TEMPLATES}\n`));
const res = compareSync(SOURCE, TEMPLATES, { compareContent: true });
const changes = (res.diffSet ?? []).filter(
- (d) => d.state !== 'equal' && d.type1 !== 'directory' && d.type2 !== 'directory'
+ (d) =>
+ d.state !== 'equal' &&
+ d.type1 !== 'directory' &&
+ d.type2 !== 'directory' &&
+ !IGNORE.has(d.name1 ?? '') &&
+ !IGNORE.has(d.name2 ?? '')
);
if (changes.length === 0) {
diff --git a/packages/sandbox/src/index.html b/packages/sandbox/src/index.html
index aeaba95b..a2779a90 100644
--- a/packages/sandbox/src/index.html
+++ b/packages/sandbox/src/index.html
@@ -4,121 +4,12 @@
Sandbox
-
-
+
+
-
-
-
-
+
+
+
diff --git a/packages/sandbox/src/main.tsx b/packages/sandbox/src/main.tsx
new file mode 100644
index 00000000..53004947
--- /dev/null
+++ b/packages/sandbox/src/main.tsx
@@ -0,0 +1,4 @@
+import { createRoot } from 'react-dom/client';
+import { App } from './shell/app';
+
+createRoot(document.getElementById('root')!).render();
diff --git a/packages/sandbox/templates/constants.ts b/packages/sandbox/templates/constants.ts
index c7e91ff8..e806428f 100644
--- a/packages/sandbox/templates/constants.ts
+++ b/packages/sandbox/templates/constants.ts
@@ -1 +1,4 @@
export const SKINS = ['default', 'minimal'] as const;
+export const PLATFORMS = ['html', 'react'] as const;
+export const STYLINGS = ['css', 'tailwind'] as const;
+export const PRESETS = ['video', 'audio', 'background-video'] as const;
diff --git a/packages/sandbox/templates/html-audio-tailwind/index.html b/packages/sandbox/templates/html-audio-tailwind/index.html
new file mode 100644
index 00000000..dbdec75b
--- /dev/null
+++ b/packages/sandbox/templates/html-audio-tailwind/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — HTML Audio Tailwind
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/html-audio-tailwind/main.ts b/packages/sandbox/templates/html-audio-tailwind/main.ts
new file mode 100644
index 00000000..b7fa7b9d
--- /dev/null
+++ b/packages/sandbox/templates/html-audio-tailwind/main.ts
@@ -0,0 +1,42 @@
+import '@videojs/html/audio/player';
+import '@videojs/html/audio/skin.tailwind';
+import '@videojs/html/audio/minimal-skin.tailwind';
+import { TAILWIND_SKIN_TAGS } from '../shared/html/skin-tags';
+import { setupAudioTailwind } from '../shared/html/tailwind-setup';
+import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '../shared/sandbox-listener';
+import type { SourceId } from '../shared/sources';
+import { SOURCES } from '../shared/sources';
+import type { Skin } from '../types';
+
+setupAudioTailwind();
+
+const html = String.raw;
+
+let currentSkin: Skin = getInitialSkin();
+let currentSource: SourceId = getInitialSource(true);
+
+function render() {
+ const tag = TAILWIND_SKIN_TAGS[currentSkin].audio;
+
+ document.getElementById('root')!.innerHTML = html`
+
+
+ <${tag}>
+
+ ${tag}>
+
+
+ `;
+}
+
+render();
+
+onSkinChange((skin) => {
+ currentSkin = skin;
+ render();
+});
+
+onSourceChange((source) => {
+ currentSource = source;
+ render();
+});
diff --git a/packages/sandbox/templates/html-audio/index.html b/packages/sandbox/templates/html-audio/index.html
new file mode 100644
index 00000000..b1618bd2
--- /dev/null
+++ b/packages/sandbox/templates/html-audio/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — HTML Audio
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/html-audio/main.ts b/packages/sandbox/templates/html-audio/main.ts
new file mode 100644
index 00000000..e7fa6650
--- /dev/null
+++ b/packages/sandbox/templates/html-audio/main.ts
@@ -0,0 +1,42 @@
+import '@videojs/html/audio/player';
+import '@videojs/html/audio/skin';
+import '@videojs/html/audio/minimal-skin';
+import { CSS_SKIN_TAGS } from '../shared/html/skin-tags';
+import { loadAudioStylesheets } from '../shared/html/stylesheets';
+import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '../shared/sandbox-listener';
+import type { SourceId } from '../shared/sources';
+import { SOURCES } from '../shared/sources';
+import type { Skin } from '../types';
+
+const html = String.raw;
+
+let currentSkin: Skin = getInitialSkin();
+let currentSource: SourceId = getInitialSource(true);
+
+function render() {
+ const tag = CSS_SKIN_TAGS[currentSkin].audio;
+
+ loadAudioStylesheets(currentSkin);
+
+ document.getElementById('root')!.innerHTML = html`
+
+
+ <${tag}>
+
+ ${tag}>
+
+
+ `;
+}
+
+render();
+
+onSkinChange((skin) => {
+ currentSkin = skin;
+ render();
+});
+
+onSourceChange((source) => {
+ currentSource = source;
+ render();
+});
diff --git a/packages/sandbox/templates/html-background-video/index.html b/packages/sandbox/templates/html-background-video/index.html
new file mode 100644
index 00000000..648f155b
--- /dev/null
+++ b/packages/sandbox/templates/html-background-video/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — HTML Background Video
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/html-background-video/main.ts b/packages/sandbox/templates/html-background-video/main.ts
new file mode 100644
index 00000000..9ddf0823
--- /dev/null
+++ b/packages/sandbox/templates/html-background-video/main.ts
@@ -0,0 +1,15 @@
+import '@videojs/html/background/skin.css';
+import '@videojs/html/background/player';
+import '@videojs/html/background/skin';
+import '@videojs/html/background/video';
+import { BACKGROUND_VIDEO_SRC } from '../shared/sources';
+
+const html = String.raw;
+
+document.getElementById('root')!.innerHTML = html`
+
+
+
+
+
+`;
diff --git a/packages/sandbox/templates/html-background/index.html b/packages/sandbox/templates/html-background/index.html
deleted file mode 100644
index 119c3d25..00000000
--- a/packages/sandbox/templates/html-background/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
- Sandbox — HTML Background
-
-
-
- ← More examples
-
-
-
-
-
diff --git a/packages/sandbox/templates/html-background/main.ts b/packages/sandbox/templates/html-background/main.ts
deleted file mode 100644
index 91a597a3..00000000
--- a/packages/sandbox/templates/html-background/main.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-// HTML sandbox — Background video player
-// http://localhost:5173/html-background/
-
-import '@videojs/html/background/skin.css';
-import '@videojs/html/background/player';
-import '@videojs/html/background/skin';
-import '@videojs/html/background/video';
-
-const html = String.raw;
-
-const root = document.getElementById('root');
-
-if (root) {
- root.innerHTML = html`
-
-
-
-
-
- `;
-}
diff --git a/packages/sandbox/templates/html-tailwind/index.html b/packages/sandbox/templates/html-tailwind/index.html
deleted file mode 100644
index 2ce1b0dc..00000000
--- a/packages/sandbox/templates/html-tailwind/index.html
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
- Sandbox — HTML & Tailwind
-
-
-
-
- ← More examples
-
-
-
-
-
-
diff --git a/packages/sandbox/templates/html-tailwind/main.ts b/packages/sandbox/templates/html-tailwind/main.ts
deleted file mode 100644
index cb66acc3..00000000
--- a/packages/sandbox/templates/html-tailwind/main.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-// HTML & Tailwind sandbox — Web player with Tailwind skins
-// http://localhost:5173/html-tailwind/
-
-import '@videojs/html/video/player';
-import '@videojs/html/video/skin.tailwind';
-import '@videojs/html/video/minimal-skin.tailwind';
-import { SKINS } from '../constants';
-import type { Skin } from '../types';
-
-const player = document.getElementById('player');
-const select = document.getElementById('skin-select') as HTMLSelectElement | null;
-
-if (player && select) {
- const html = String.raw;
-
- const VIDEO_SRC = 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4';
-
- const skinTags: Record = {
- default: 'video-skin-tailwind',
- minimal: 'video-minimal-skin-tailwind',
- };
-
- function render(skin: Skin) {
- const tag = skinTags[skin];
-
- player!.innerHTML = html`
-
- <${tag}>
-
- ${tag}>
-
- `;
- }
-
- for (const skin of SKINS) {
- const option = document.createElement('option');
- option.value = skin;
- option.textContent = skin;
- select.appendChild(option);
- }
- select.addEventListener('change', () => render(select.value as Skin));
-
- render('default');
-}
diff --git a/packages/sandbox/templates/html-video-tailwind/index.html b/packages/sandbox/templates/html-video-tailwind/index.html
new file mode 100644
index 00000000..da7a0139
--- /dev/null
+++ b/packages/sandbox/templates/html-video-tailwind/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — HTML Video Tailwind
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/html-video-tailwind/main.ts b/packages/sandbox/templates/html-video-tailwind/main.ts
new file mode 100644
index 00000000..0547a67f
--- /dev/null
+++ b/packages/sandbox/templates/html-video-tailwind/main.ts
@@ -0,0 +1,40 @@
+import '@videojs/html/video/player';
+import '@videojs/html/video/skin.tailwind';
+import '@videojs/html/video/minimal-skin.tailwind';
+import { TAILWIND_SKIN_TAGS } from '../shared/html/skin-tags';
+import { setupVideoTailwind } from '../shared/html/tailwind-setup';
+import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '../shared/sandbox-listener';
+import type { SourceId } from '../shared/sources';
+import { SOURCES } from '../shared/sources';
+import type { Skin } from '../types';
+
+setupVideoTailwind();
+
+const html = String.raw;
+
+let currentSkin: Skin = getInitialSkin();
+let currentSource: SourceId = getInitialSource();
+
+function render() {
+ const tag = TAILWIND_SKIN_TAGS[currentSkin].video;
+
+ document.getElementById('root')!.innerHTML = html`
+
+ <${tag} class="w-full aspect-video max-w-4xl mx-auto">
+
+ ${tag}>
+
+ `;
+}
+
+render();
+
+onSkinChange((skin) => {
+ currentSkin = skin;
+ render();
+});
+
+onSourceChange((source) => {
+ currentSource = source;
+ render();
+});
diff --git a/packages/sandbox/templates/html-video/index.html b/packages/sandbox/templates/html-video/index.html
new file mode 100644
index 00000000..70e9ffcf
--- /dev/null
+++ b/packages/sandbox/templates/html-video/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — HTML Video
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/html-video/main.ts b/packages/sandbox/templates/html-video/main.ts
new file mode 100644
index 00000000..139504d4
--- /dev/null
+++ b/packages/sandbox/templates/html-video/main.ts
@@ -0,0 +1,40 @@
+import '@videojs/html/video/player';
+import '@videojs/html/video/skin';
+import '@videojs/html/video/minimal-skin';
+import { CSS_SKIN_TAGS } from '../shared/html/skin-tags';
+import { loadVideoStylesheets } from '../shared/html/stylesheets';
+import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '../shared/sandbox-listener';
+import type { SourceId } from '../shared/sources';
+import { SOURCES } from '../shared/sources';
+import type { Skin } from '../types';
+
+const html = String.raw;
+
+let currentSkin: Skin = getInitialSkin();
+let currentSource: SourceId = getInitialSource();
+
+function render() {
+ const tag = CSS_SKIN_TAGS[currentSkin].video;
+
+ loadVideoStylesheets(currentSkin);
+
+ document.getElementById('root')!.innerHTML = html`
+
+ <${tag} class="w-full aspect-video max-w-4xl mx-auto">
+
+ ${tag}>
+
+ `;
+}
+
+render();
+
+onSkinChange((skin) => {
+ currentSkin = skin;
+ render();
+});
+
+onSourceChange((source) => {
+ currentSource = source;
+ render();
+});
diff --git a/packages/sandbox/templates/html/index.html b/packages/sandbox/templates/html/index.html
deleted file mode 100644
index 12510f47..00000000
--- a/packages/sandbox/templates/html/index.html
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
- Sandbox — HTML
-
-
-
- ← More examples
-
-
-
-
-
diff --git a/packages/sandbox/templates/html/main.ts b/packages/sandbox/templates/html/main.ts
deleted file mode 100644
index 2f3518d3..00000000
--- a/packages/sandbox/templates/html/main.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-// HTML sandbox — Web player with skin switching
-// http://localhost:5173/html/
-
-import '@videojs/html/video/player';
-import '@videojs/html/video/skin';
-import '@videojs/html/video/minimal-skin';
-import { SKINS } from '../constants';
-import type { Skin } from '../types';
-
-const player = document.getElementById('player');
-const select = document.getElementById('skin-select') as HTMLSelectElement | null;
-
-if (player && select) {
- const html = String.raw;
-
- const VIDEO_SRC = 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4';
-
- const skinTags: Record = {
- default: 'video-skin',
- minimal: 'video-minimal-skin',
- };
-
- const stylesheets: Record = {
- default: new URL('@videojs/html/video/skin.css', import.meta.url).href,
- minimal: new URL('@videojs/html/video/minimal-skin.css', import.meta.url).href,
- };
-
- let currentLink: HTMLLinkElement | null = null;
-
- function loadStylesheet(url: string) {
- currentLink?.remove();
- const link = document.createElement('link');
- link.rel = 'stylesheet';
- link.href = url;
- document.head.appendChild(link);
- currentLink = link;
- }
-
- function render(skin: Skin) {
- const tag = skinTags[skin];
-
- loadStylesheet(stylesheets[skin]);
-
- player!.innerHTML = html`
-
- <${tag}>
-
- ${tag}>
-
- `;
- }
-
- for (const skin of SKINS) {
- const option = document.createElement('option');
- option.value = skin;
- option.textContent = skin;
- select.appendChild(option);
- }
- select.addEventListener('change', () => render(select.value as Skin));
-
- render('default');
-}
diff --git a/packages/sandbox/templates/index.html b/packages/sandbox/templates/index.html
deleted file mode 100644
index aeaba95b..00000000
--- a/packages/sandbox/templates/index.html
+++ /dev/null
@@ -1,124 +0,0 @@
-
-
-
-
-
- Sandbox
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/sandbox/templates/react-audio-tailwind/index.html b/packages/sandbox/templates/react-audio-tailwind/index.html
new file mode 100644
index 00000000..1b58a419
--- /dev/null
+++ b/packages/sandbox/templates/react-audio-tailwind/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — React Audio Tailwind
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/react-audio-tailwind/main.tsx b/packages/sandbox/templates/react-audio-tailwind/main.tsx
new file mode 100644
index 00000000..9948c4d1
--- /dev/null
+++ b/packages/sandbox/templates/react-audio-tailwind/main.tsx
@@ -0,0 +1,22 @@
+import { Audio } from '@videojs/react/audio';
+import { createRoot } from 'react-dom/client';
+import { AudioProvider } from '../shared/react/providers';
+import { AudioSkinComponent } from '../shared/react/skins';
+import { useSkin } from '../shared/react/use-skin';
+import { useSource } from '../shared/react/use-source';
+import { SOURCES } from '../shared/sources';
+
+function App() {
+ const skin = useSkin();
+ const source = useSource(true);
+
+ return (
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById('root')!).render();
diff --git a/packages/sandbox/templates/react-audio/index.html b/packages/sandbox/templates/react-audio/index.html
new file mode 100644
index 00000000..496b0b8f
--- /dev/null
+++ b/packages/sandbox/templates/react-audio/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — React Audio
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/react-audio/main.tsx b/packages/sandbox/templates/react-audio/main.tsx
new file mode 100644
index 00000000..897cc6b8
--- /dev/null
+++ b/packages/sandbox/templates/react-audio/main.tsx
@@ -0,0 +1,24 @@
+import '@videojs/react/audio/skin.css';
+import '@videojs/react/audio/minimal-skin.css';
+import { Audio } from '@videojs/react/audio';
+import { createRoot } from 'react-dom/client';
+import { AudioProvider } from '../shared/react/providers';
+import { AudioSkinComponent } from '../shared/react/skins';
+import { useSkin } from '../shared/react/use-skin';
+import { useSource } from '../shared/react/use-source';
+import { SOURCES } from '../shared/sources';
+
+function App() {
+ const skin = useSkin();
+ const source = useSource(true);
+
+ return (
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById('root')!).render();
diff --git a/packages/sandbox/templates/react-background-video/index.html b/packages/sandbox/templates/react-background-video/index.html
new file mode 100644
index 00000000..cfabe4c1
--- /dev/null
+++ b/packages/sandbox/templates/react-background-video/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — React Background Video
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/react-background-video/main.tsx b/packages/sandbox/templates/react-background-video/main.tsx
new file mode 100644
index 00000000..567d6572
--- /dev/null
+++ b/packages/sandbox/templates/react-background-video/main.tsx
@@ -0,0 +1,17 @@
+import '@videojs/react/background/skin.css';
+import { BackgroundVideo, BackgroundVideoSkin } from '@videojs/react/background';
+import { createRoot } from 'react-dom/client';
+import { BackgroundVideoProvider } from '../shared/react/providers';
+import { BACKGROUND_VIDEO_SRC } from '../shared/sources';
+
+function App() {
+ return (
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById('root')!).render();
diff --git a/packages/sandbox/templates/react-background/background.tsx b/packages/sandbox/templates/react-background/background.tsx
deleted file mode 100644
index 7ed60e6f..00000000
--- a/packages/sandbox/templates/react-background/background.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import '@videojs/react/background/skin.css';
-import { createPlayer } from '@videojs/react';
-import { BackgroundVideo, BackgroundVideoSkin, backgroundFeatures } from '@videojs/react/background';
-import { createRoot } from 'react-dom/client';
-
-const { Provider: BackgroundVideoProvider } = createPlayer({
- features: backgroundFeatures,
-});
-
-export function App() {
- return (
-
-
-
-
-
- );
-}
-
-createRoot(document.getElementById('root')!).render();
diff --git a/packages/sandbox/templates/react-background/index.html b/packages/sandbox/templates/react-background/index.html
deleted file mode 100644
index 24649407..00000000
--- a/packages/sandbox/templates/react-background/index.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
- Sandbox — React Background
-
-
-
- ← More examples
-
-
-
-
-
diff --git a/packages/sandbox/templates/react-tailwind/index.html b/packages/sandbox/templates/react-tailwind/index.html
deleted file mode 100644
index 7ea6512c..00000000
--- a/packages/sandbox/templates/react-tailwind/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
- Sandbox — React & Tailwind
-
-
-
- ← More examples
-
-
-
-
-
diff --git a/packages/sandbox/templates/react-tailwind/main.tsx b/packages/sandbox/templates/react-tailwind/main.tsx
deleted file mode 100644
index 998f83fb..00000000
--- a/packages/sandbox/templates/react-tailwind/main.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-// React & Tailwind Sandbox
-// http://localhost:5173/react-tailwind/
-
-import { createPlayer } from '@videojs/react';
-import {
- MinimalVideoSkinTailwind,
- Video,
- type VideoSkinProps,
- VideoSkinTailwind,
- videoFeatures,
-} from '@videojs/react/video';
-import { createRoot } from 'react-dom/client';
-import { SKINS } from '../constants';
-import type { Skin } from '../types';
-import { useSkinSwitcher } from '../utils/use-skin-switcher';
-
-const { Provider } = createPlayer({
- features: videoFeatures,
-});
-
-function SkinComponent({ skin, ...props }: { skin: Skin } & VideoSkinProps) {
- switch (skin) {
- case 'default':
- return ;
- case 'minimal':
- return ;
- }
-}
-
-function App() {
- const [skin, setSkin] = useSkinSwitcher();
-
- return (
-
-
-
-
-
-
-
-
-
- );
-}
-
-const root = document.getElementById('root');
-if (root) createRoot(root).render();
diff --git a/packages/sandbox/templates/react-video-tailwind/index.html b/packages/sandbox/templates/react-video-tailwind/index.html
new file mode 100644
index 00000000..20753fb3
--- /dev/null
+++ b/packages/sandbox/templates/react-video-tailwind/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — React Video Tailwind
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/react-video-tailwind/main.tsx b/packages/sandbox/templates/react-video-tailwind/main.tsx
new file mode 100644
index 00000000..be43d2c6
--- /dev/null
+++ b/packages/sandbox/templates/react-video-tailwind/main.tsx
@@ -0,0 +1,22 @@
+import { Video } from '@videojs/react/video';
+import { createRoot } from 'react-dom/client';
+import { VideoProvider } from '../shared/react/providers';
+import { VideoSkinComponent } from '../shared/react/skins';
+import { useSkin } from '../shared/react/use-skin';
+import { useSource } from '../shared/react/use-source';
+import { SOURCES } from '../shared/sources';
+
+function App() {
+ const skin = useSkin();
+ const source = useSource();
+
+ return (
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById('root')!).render();
diff --git a/packages/sandbox/templates/react-video/index.html b/packages/sandbox/templates/react-video/index.html
new file mode 100644
index 00000000..29ec7003
--- /dev/null
+++ b/packages/sandbox/templates/react-video/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Sandbox — React Video
+
+
+
+
+
+
+
+
+
diff --git a/packages/sandbox/templates/react-video/main.tsx b/packages/sandbox/templates/react-video/main.tsx
new file mode 100644
index 00000000..a21208fc
--- /dev/null
+++ b/packages/sandbox/templates/react-video/main.tsx
@@ -0,0 +1,24 @@
+import '@videojs/react/video/skin.css';
+import '@videojs/react/video/minimal-skin.css';
+import { Video } from '@videojs/react/video';
+import { createRoot } from 'react-dom/client';
+import { VideoProvider } from '../shared/react/providers';
+import { VideoSkinComponent } from '../shared/react/skins';
+import { useSkin } from '../shared/react/use-skin';
+import { useSource } from '../shared/react/use-source';
+import { SOURCES } from '../shared/sources';
+
+function App() {
+ const skin = useSkin();
+ const source = useSource();
+
+ return (
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById('root')!).render();
diff --git a/packages/sandbox/templates/react/index.html b/packages/sandbox/templates/react/index.html
deleted file mode 100644
index 93733687..00000000
--- a/packages/sandbox/templates/react/index.html
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
- Sandbox — React
-
-
-
-
- ← More examples
-
-
-
-
-
-
diff --git a/packages/sandbox/templates/react/main.tsx b/packages/sandbox/templates/react/main.tsx
deleted file mode 100644
index 243a3507..00000000
--- a/packages/sandbox/templates/react/main.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-// React sandbox — React player
-// http://localhost:5173/react/
-
-import { createPlayer } from '@videojs/react';
-import { HlsVideo } from '@videojs/react/media/hls-video';
-import { videoFeatures } from '@videojs/react/video';
-import { useState } from 'react';
-import { createRoot } from 'react-dom/client';
-import { SKINS } from '../constants';
-import type { Skin } from '../types';
-import { useSkinSwitcher } from '../utils/use-skin-switcher';
-import { SkinComponent } from './skins';
-
-const { Provider } = createPlayer({
- features: videoFeatures,
-});
-
-const HLS_SOURCES = [
- 'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8',
- 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8',
-];
-
-function App() {
- const [skin, setSkin] = useSkinSwitcher();
- const [src, setSrc] = useState(HLS_SOURCES[0]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-const root = document.getElementById('root');
-if (root) createRoot(root).render();
diff --git a/packages/sandbox/templates/react/skins.tsx b/packages/sandbox/templates/react/skins.tsx
deleted file mode 100644
index ae7e4074..00000000
--- a/packages/sandbox/templates/react/skins.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { VideoSkinProps } from '@videojs/react/video';
-import { MinimalVideoSkin, VideoSkin } from '@videojs/react/video';
-import minimalSkinUrl from '@videojs/react/video/minimal-skin.css?url';
-import defaultSkinUrl from '@videojs/react/video/skin.css?url';
-import { useEffect } from 'react';
-import type { Skin } from '../types';
-
-const stylesheets: Record = {
- default: defaultSkinUrl,
- minimal: minimalSkinUrl,
-};
-
-function useStylesheet(url: string) {
- useEffect(() => {
- const link = document.createElement('link');
- link.rel = 'stylesheet';
- link.href = url;
- document.head.appendChild(link);
- return () => {
- link.remove();
- };
- }, [url]);
-}
-
-export function SkinComponent({ skin, ...props }: { skin: Skin } & VideoSkinProps) {
- useStylesheet(stylesheets[skin]);
-
- switch (skin) {
- case 'default':
- return ;
- case 'minimal':
- return ;
- }
-}
diff --git a/packages/sandbox/templates/shared/html/skin-tags.ts b/packages/sandbox/templates/shared/html/skin-tags.ts
new file mode 100644
index 00000000..1adb95d5
--- /dev/null
+++ b/packages/sandbox/templates/shared/html/skin-tags.ts
@@ -0,0 +1,13 @@
+import type { Skin } from '../../types';
+
+type SkinTagMap = Record;
+
+export const CSS_SKIN_TAGS: SkinTagMap = {
+ default: { video: 'video-skin', audio: 'audio-skin' },
+ minimal: { video: 'video-minimal-skin', audio: 'audio-minimal-skin' },
+};
+
+export const TAILWIND_SKIN_TAGS: SkinTagMap = {
+ default: { video: 'video-skin-tailwind', audio: 'audio-skin-tailwind' },
+ minimal: { video: 'video-minimal-skin-tailwind', audio: 'audio-minimal-skin-tailwind' },
+};
diff --git a/packages/sandbox/templates/shared/html/stylesheets.ts b/packages/sandbox/templates/shared/html/stylesheets.ts
new file mode 100644
index 00000000..c4563643
--- /dev/null
+++ b/packages/sandbox/templates/shared/html/stylesheets.ts
@@ -0,0 +1,30 @@
+import type { Skin } from '../../types';
+
+const videoStylesheets: Record = {
+ default: new URL('@videojs/html/video/skin.css', import.meta.url).href,
+ minimal: new URL('@videojs/html/video/minimal-skin.css', import.meta.url).href,
+};
+
+const audioStylesheets: Record = {
+ default: new URL('@videojs/html/audio/skin.css', import.meta.url).href,
+ minimal: new URL('@videojs/html/audio/minimal-skin.css', import.meta.url).href,
+};
+
+function loadStylesheet(url: string) {
+ // Remove any existing link for this URL to force reload
+ const existing = document.querySelector(`link[rel="stylesheet"][href="${url}"]`);
+ existing?.remove();
+
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = url;
+ document.head.appendChild(link);
+}
+
+export function loadVideoStylesheets(skin: Skin) {
+ loadStylesheet(videoStylesheets[skin]);
+}
+
+export function loadAudioStylesheets(skin: Skin) {
+ loadStylesheet(audioStylesheets[skin]);
+}
diff --git a/packages/sandbox/templates/shared/html/tailwind-setup.ts b/packages/sandbox/templates/shared/html/tailwind-setup.ts
new file mode 100644
index 00000000..d899c1b1
--- /dev/null
+++ b/packages/sandbox/templates/shared/html/tailwind-setup.ts
@@ -0,0 +1,18 @@
+import { MinimalAudioSkinTailwindElement } from '@videojs/html/audio/minimal-skin.tailwind';
+import { AudioSkinTailwindElement } from '@videojs/html/audio/skin.tailwind';
+import { MinimalVideoSkinTailwindElement } from '@videojs/html/video/minimal-skin.tailwind';
+import { VideoSkinTailwindElement } from '@videojs/html/video/skin.tailwind';
+import tailwindCSS from '../../styles.css?inline';
+
+const tailwindStyles = new CSSStyleSheet();
+tailwindStyles.replaceSync(tailwindCSS);
+
+export function setupVideoTailwind() {
+ VideoSkinTailwindElement.styles = tailwindStyles;
+ MinimalVideoSkinTailwindElement.styles = tailwindStyles;
+}
+
+export function setupAudioTailwind() {
+ AudioSkinTailwindElement.styles = tailwindStyles;
+ MinimalAudioSkinTailwindElement.styles = tailwindStyles;
+}
diff --git a/packages/sandbox/templates/shared/react/providers.ts b/packages/sandbox/templates/shared/react/providers.ts
new file mode 100644
index 00000000..8e6980f9
--- /dev/null
+++ b/packages/sandbox/templates/shared/react/providers.ts
@@ -0,0 +1,16 @@
+import { createPlayer } from '@videojs/react';
+import { audioFeatures } from '@videojs/react/audio';
+import { backgroundFeatures } from '@videojs/react/background';
+import { videoFeatures } from '@videojs/react/video';
+
+export const { Provider: VideoProvider } = createPlayer({
+ features: videoFeatures,
+});
+
+export const { Provider: AudioProvider } = createPlayer({
+ features: audioFeatures,
+});
+
+export const { Provider: BackgroundVideoProvider } = createPlayer({
+ features: backgroundFeatures,
+});
diff --git a/packages/sandbox/templates/shared/react/skins.tsx b/packages/sandbox/templates/shared/react/skins.tsx
new file mode 100644
index 00000000..adaa2540
--- /dev/null
+++ b/packages/sandbox/templates/shared/react/skins.tsx
@@ -0,0 +1,45 @@
+import type { AudioSkinProps } from '@videojs/react/audio';
+import { AudioSkin, AudioSkinTailwind, MinimalAudioSkin, MinimalAudioSkinTailwind } from '@videojs/react/audio';
+import type { VideoSkinProps } from '@videojs/react/video';
+import { MinimalVideoSkin, MinimalVideoSkinTailwind, VideoSkin, VideoSkinTailwind } from '@videojs/react/video';
+import type { Skin, Styling } from '../../types';
+
+type VideoSkinComponentProps = { skin: Skin; styling: Styling } & VideoSkinProps;
+
+export function VideoSkinComponent({ skin, styling, ...props }: VideoSkinComponentProps) {
+ if (styling === 'tailwind') {
+ switch (skin) {
+ case 'default':
+ return ;
+ case 'minimal':
+ return ;
+ }
+ }
+
+ switch (skin) {
+ case 'default':
+ return ;
+ case 'minimal':
+ return ;
+ }
+}
+
+type AudioSkinComponentProps = { skin: Skin; styling: Styling } & AudioSkinProps;
+
+export function AudioSkinComponent({ skin, styling, ...props }: AudioSkinComponentProps) {
+ if (styling === 'tailwind') {
+ switch (skin) {
+ case 'default':
+ return ;
+ case 'minimal':
+ return ;
+ }
+ }
+
+ switch (skin) {
+ case 'default':
+ return ;
+ case 'minimal':
+ return ;
+ }
+}
diff --git a/packages/sandbox/templates/shared/react/use-skin.ts b/packages/sandbox/templates/shared/react/use-skin.ts
new file mode 100644
index 00000000..f82b2d4a
--- /dev/null
+++ b/packages/sandbox/templates/shared/react/use-skin.ts
@@ -0,0 +1,9 @@
+import { useEffect, useState } from 'react';
+import type { Skin } from '../../types';
+import { getInitialSkin, onSkinChange } from '../sandbox-listener';
+
+export function useSkin(): Skin {
+ const [skin, setSkin] = useState(getInitialSkin);
+ useEffect(() => onSkinChange(setSkin), []);
+ return skin;
+}
diff --git a/packages/sandbox/templates/shared/react/use-source.ts b/packages/sandbox/templates/shared/react/use-source.ts
new file mode 100644
index 00000000..ca55f148
--- /dev/null
+++ b/packages/sandbox/templates/shared/react/use-source.ts
@@ -0,0 +1,9 @@
+import { useEffect, useState } from 'react';
+import { getInitialSource, onSourceChange } from '../sandbox-listener';
+import type { SourceId } from '../sources';
+
+export function useSource(audioOnly?: boolean): SourceId {
+ const [source, setSource] = useState(() => getInitialSource(audioOnly));
+ useEffect(() => onSourceChange(setSource), []);
+ return source;
+}
diff --git a/packages/sandbox/templates/shared/sandbox-listener.ts b/packages/sandbox/templates/shared/sandbox-listener.ts
new file mode 100644
index 00000000..0108eeb4
--- /dev/null
+++ b/packages/sandbox/templates/shared/sandbox-listener.ts
@@ -0,0 +1,63 @@
+import type { Skin } from '../types';
+import { createWebStorageStore } from '../utils/create-web-storage-store';
+import { DEFAULT_AUDIO_SOURCE, SOURCES, type SourceId } from './sources';
+
+const params = new URLSearchParams(window.location.search);
+
+const skinStore = createWebStorageStore('local', 'skin', 'default');
+const sourceStore = createWebStorageStore('local', 'source', 'hls-1');
+
+// Apply query param overrides to localStorage so they persist
+const skinParam = params.get('skin') as Skin | null;
+if (skinParam) skinStore.setValue(skinParam);
+
+const sourceParam = params.get('source') as SourceId | null;
+if (sourceParam) sourceStore.setValue(sourceParam);
+
+export function getInitialSkin(): Skin {
+ return skinStore.getSnapshot();
+}
+
+export function onSkinChange(callback: (skin: Skin) => void): () => void {
+ const unsubStore = skinStore.subscribe(() => callback(skinStore.getSnapshot()));
+
+ const handler = (event: MessageEvent) => {
+ if (event.data?.type === 'skin-change' && event.data.skin !== skinStore.getSnapshot()) {
+ skinStore.setValue(event.data.skin);
+ }
+ };
+
+ window.addEventListener('message', handler);
+
+ return () => {
+ unsubStore();
+ window.removeEventListener('message', handler);
+ };
+}
+
+export function getInitialSource(audioOnly?: boolean): SourceId {
+ const stored = sourceStore.getSnapshot();
+
+ if (audioOnly && SOURCES[stored].type !== 'mp4') {
+ return DEFAULT_AUDIO_SOURCE;
+ }
+
+ return stored;
+}
+
+export function onSourceChange(callback: (source: SourceId) => void): () => void {
+ const unsubStore = sourceStore.subscribe(() => callback(sourceStore.getSnapshot()));
+
+ const handler = (event: MessageEvent) => {
+ if (event.data?.type === 'source-change' && event.data.source !== sourceStore.getSnapshot()) {
+ sourceStore.setValue(event.data.source);
+ }
+ };
+
+ window.addEventListener('message', handler);
+
+ return () => {
+ unsubStore();
+ window.removeEventListener('message', handler);
+ };
+}
diff --git a/packages/sandbox/templates/shared/sources.ts b/packages/sandbox/templates/shared/sources.ts
new file mode 100644
index 00000000..561055c1
--- /dev/null
+++ b/packages/sandbox/templates/shared/sources.ts
@@ -0,0 +1,36 @@
+export type SourceId = 'hls-1' | 'hls-2' | 'hls-3' | 'hls-4' | 'mp4-1';
+
+export const SOURCES: Record = {
+ 'hls-1': {
+ label: 'HLS - Big Buck Bunny',
+ url: 'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8',
+ type: 'hls',
+ },
+ 'hls-2': {
+ label: 'HLS - 2',
+ url: 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8',
+ type: 'hls',
+ },
+ 'hls-3': {
+ label: 'HLS - Dancing Dude',
+ url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4.m3u8',
+ type: 'hls',
+ },
+ 'hls-4': {
+ label: 'HLS - Plyr',
+ url: 'https://stream.mux.com/lyrKpPcGfqyzeI00jZAfW6MvP6GNPrkML.m3u8',
+ type: 'hls',
+ },
+ 'mp4-1': {
+ label: 'MP4 - Dancing Dude',
+ url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4',
+ type: 'mp4',
+ },
+};
+
+export const SOURCE_IDS = Object.keys(SOURCES) as SourceId[];
+export const MP4_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'mp4');
+export const DEFAULT_SOURCE: SourceId = 'hls-1';
+export const DEFAULT_AUDIO_SOURCE: SourceId = 'mp4-1';
+
+export const BACKGROUND_VIDEO_SRC = 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008/low.mp4';
diff --git a/packages/sandbox/templates/shell/app.tsx b/packages/sandbox/templates/shell/app.tsx
new file mode 100644
index 00000000..b705fc8e
--- /dev/null
+++ b/packages/sandbox/templates/shell/app.tsx
@@ -0,0 +1,102 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { PLATFORMS, PRESETS, STYLINGS } from '../constants';
+import type { SourceId } from '../shared/sources';
+import { DEFAULT_AUDIO_SOURCE, MP4_SOURCE_IDS, SOURCE_IDS, SOURCES } from '../shared/sources';
+import type { Platform, Preset, Styling } from '../types';
+import { useSkinSwitcher } from '../utils/use-skin-switcher';
+import { useSourceSwitcher } from '../utils/use-source-switcher';
+import { Navbar } from './navbar';
+import { Preview } from './preview';
+
+function getPagePath(platform: Platform, styling: Styling, preset: Preset): string {
+ if (preset === 'background-video') return `/${platform}-background-video/`;
+ if (styling === 'tailwind') return `/${platform}-${preset}-tailwind/`;
+ return `/${platform}-${preset}/`;
+}
+
+function readParams() {
+ const params = new URLSearchParams(location.search);
+ return {
+ platform: (params.get('platform') ?? 'html') as Platform,
+ styling: (params.get('styling') ?? 'css') as Styling,
+ preset: (params.get('preset') ?? 'video') as Preset,
+ };
+}
+
+export function App() {
+ const initial = useMemo(readParams, []);
+ const [platform, setPlatform] = useState(initial.platform);
+ const [styling, setStyling] = useState(initial.styling);
+ const [preset, setPreset] = useState(initial.preset);
+ const [skin, setSkin] = useSkinSwitcher();
+ const [source, setSource] = useSourceSwitcher();
+ const iframeRef = useRef(null);
+
+ // Derive the page URL
+ const pagePath = getPagePath(platform, styling, preset);
+
+ // Update URL when filters change
+ useEffect(() => {
+ const params = new URLSearchParams({ platform, styling, preset });
+ const url = `/?${params.toString()}`;
+ history.replaceState(null, '', url);
+ }, [platform, styling, preset]);
+
+ // Send postMessage to iframe for skin changes (skip initial mount — iframe reads localStorage)
+ const skinRef = useRef(skin);
+ useEffect(() => {
+ if (skinRef.current === skin) return;
+ skinRef.current = skin;
+ iframeRef.current?.contentWindow?.postMessage({ type: 'skin-change', skin }, '*');
+ }, [skin]);
+
+ // Send postMessage to iframe for source changes (skip initial mount — iframe reads localStorage)
+ const sourceRef = useRef(source);
+ useEffect(() => {
+ if (sourceRef.current === source) return;
+ sourceRef.current = source;
+ iframeRef.current?.contentWindow?.postMessage({ type: 'source-change', source }, '*');
+ }, [source]);
+
+ // Constrain source to MP4 when switching to audio
+ useEffect(() => {
+ if (preset === 'audio' && SOURCES[source].type !== 'mp4') {
+ setSource(DEFAULT_AUDIO_SOURCE);
+ }
+ }, [preset, source, setSource]);
+
+ // Constrain styling when switching to background-video
+ useEffect(() => {
+ if (preset === 'background-video' && styling === 'tailwind') {
+ setStyling('css');
+ }
+ }, [preset, styling]);
+
+ const availableSources = preset === 'audio' ? MP4_SOURCE_IDS : SOURCE_IDS;
+
+ const handleSourceChange = useCallback((value: string) => setSource(value as SourceId), [setSource]);
+
+ return (
+
+ );
+}
diff --git a/packages/sandbox/templates/shell/navbar.tsx b/packages/sandbox/templates/shell/navbar.tsx
new file mode 100644
index 00000000..9b4d9ee7
--- /dev/null
+++ b/packages/sandbox/templates/shell/navbar.tsx
@@ -0,0 +1,178 @@
+import type { SKINS } from '../constants';
+import type { SourceId } from '../shared/sources';
+import type { Platform, Preset, Skin, Styling } from '../types';
+
+type NavbarProps = {
+ platform: Platform;
+ onPlatformChange: (value: Platform) => void;
+ styling: Styling;
+ onStylingChange: (value: Styling) => void;
+ preset: Preset;
+ onPresetChange: (value: Preset) => void;
+ skin: Skin;
+ onSkinChange: (value: Skin) => void;
+ source: SourceId;
+ onSourceChange: (value: string) => void;
+ availableSources: readonly SourceId[];
+ isBackgroundVideo: boolean;
+ platforms: readonly Platform[];
+ stylings: readonly Styling[];
+ presets: readonly Preset[];
+ sources: Record;
+};
+
+const SKIN_OPTIONS: readonly Skin[] = ['default', 'minimal'] satisfies readonly (typeof SKINS)[number][];
+
+const PLATFORM_LABELS: Record = {
+ html: 'HTML',
+ react: 'React',
+};
+
+const PRESET_LABELS: Record = {
+ video: 'Video',
+ audio: 'Audio',
+ 'background-video': 'Background Video',
+};
+
+export function Navbar({
+ platform,
+ onPlatformChange,
+ styling,
+ onStylingChange,
+ preset,
+ onPresetChange,
+ skin,
+ onSkinChange,
+ source,
+ onSourceChange,
+ availableSources,
+ isBackgroundVideo,
+ platforms,
+ stylings,
+ presets,
+ sources,
+}: NavbarProps) {
+ return (
+
+ Video.js v10
+
+
+
+
+
+
+
+
+ );
+}
+
+function capitalize(s: string): string {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+type SelectOption = {
+ value: string;
+ label: string;
+ disabled?: boolean;
+};
+
+type SelectProps = {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ options: SelectOption[];
+ disabled?: boolean;
+};
+
+function Select({ label, value, onChange, options, disabled }: SelectProps) {
+ return (
+
+
{label}
+
+
onChange(e.target.value)}
+ disabled={disabled}
+ className="h-8 appearance-none rounded-md border-none bg-clip-border ring ring-zinc-800/10 bg-white pl-3 pr-8 text-[13px] font-medium text-zinc-950 shadow-xs shadow-black/20 transition-colors hover:bg-zinc-50 focus:outline-2 focus:outline-zinc-950 focus:outline-offset-2 disabled:pointer-events-none disabled:opacity-50"
+ >
+ {options.map((opt) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/packages/sandbox/templates/shell/preview.tsx b/packages/sandbox/templates/shell/preview.tsx
new file mode 100644
index 00000000..35aa384e
--- /dev/null
+++ b/packages/sandbox/templates/shell/preview.tsx
@@ -0,0 +1,43 @@
+import { forwardRef } from 'react';
+import type { SourceId } from '../shared/sources';
+import type { Skin } from '../types';
+
+type PreviewProps = {
+ pagePath: string;
+ skin: Skin;
+ source: SourceId;
+};
+
+export const Preview = forwardRef(function Preview({ pagePath, skin, source }, ref) {
+ const openUrl = `${pagePath}?skin=${encodeURIComponent(skin)}&source=${encodeURIComponent(source)}`;
+
+ return (
+
+
+ Open
+
+
+
+
+ );
+});
diff --git a/packages/sandbox/templates/types.ts b/packages/sandbox/templates/types.ts
index f6871e70..9be39426 100644
--- a/packages/sandbox/templates/types.ts
+++ b/packages/sandbox/templates/types.ts
@@ -1,3 +1,6 @@
-import type { SKINS } from './constants';
+import type { PLATFORMS, PRESETS, SKINS, STYLINGS } from './constants';
export type Skin = (typeof SKINS)[number];
+export type Platform = (typeof PLATFORMS)[number];
+export type Styling = (typeof STYLINGS)[number];
+export type Preset = (typeof PRESETS)[number];
diff --git a/packages/sandbox/templates/utils/create-web-storage-store.ts b/packages/sandbox/templates/utils/create-web-storage-store.ts
new file mode 100644
index 00000000..5186f953
--- /dev/null
+++ b/packages/sandbox/templates/utils/create-web-storage-store.ts
@@ -0,0 +1,50 @@
+export type WebStorageType = 'local' | 'session';
+
+export type WebStorageSerializableValue =
+ | string
+ | boolean
+ | number
+ | null
+ | Record
+ | WebStorageSerializableValue[];
+
+type Subscriber = () => void;
+
+export function createWebStorageStore(
+ type: WebStorageType,
+ key: string,
+ initialValue: T
+) {
+ const listeners = new Set();
+
+ const getSnapshot = (): T => {
+ const data = type === 'local' ? localStorage.getItem(key) : sessionStorage.getItem(key);
+ return data ? JSON.parse(data) : initialValue;
+ };
+
+ const setValue = (value: T) => {
+ if (type === 'local') {
+ localStorage.setItem(key, JSON.stringify(value));
+ } else {
+ sessionStorage.setItem(key, JSON.stringify(value));
+ }
+ listeners.forEach((l) => l());
+ };
+
+ const subscribe = (listener: Subscriber) => {
+ listeners.add(listener);
+
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === key) listener();
+ };
+
+ window.addEventListener('storage', onStorage);
+
+ return () => {
+ listeners.delete(listener);
+ window.removeEventListener('storage', onStorage);
+ };
+ };
+
+ return { getSnapshot, setValue, subscribe };
+}
diff --git a/packages/sandbox/templates/utils/use-external-store.ts b/packages/sandbox/templates/utils/use-external-store.ts
new file mode 100644
index 00000000..034bac08
--- /dev/null
+++ b/packages/sandbox/templates/utils/use-external-store.ts
@@ -0,0 +1,5 @@
+import { useSyncExternalStore } from 'react';
+
+export function useExternalStore(store: { subscribe: (listener: () => void) => () => void; getSnapshot: () => T }) {
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
+}
diff --git a/packages/sandbox/templates/utils/use-source-switcher.ts b/packages/sandbox/templates/utils/use-source-switcher.ts
new file mode 100644
index 00000000..b3ac8b7b
--- /dev/null
+++ b/packages/sandbox/templates/utils/use-source-switcher.ts
@@ -0,0 +1,6 @@
+import type { SourceId } from '../shared/sources';
+import { useWebStorage } from './use-web-storage';
+
+export function useSourceSwitcher() {
+ return useWebStorage('local', 'source', 'hls-1');
+}
diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json
index 6296f3e3..9e594623 100644
--- a/packages/sandbox/tsconfig.json
+++ b/packages/sandbox/tsconfig.json
@@ -6,5 +6,5 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx"
},
- "include": ["src", "scripts", "templates", "types"]
+ "include": ["src", "templates", "scripts", "types"]
}
diff --git a/packages/sandbox/vite.config.ts b/packages/sandbox/vite.config.ts
index 93399ed4..f61f1b8a 100644
--- a/packages/sandbox/vite.config.ts
+++ b/packages/sandbox/vite.config.ts
@@ -18,12 +18,16 @@ export default defineConfig({
rollupOptions: {
input: {
main: resolve(__dirname, 'src/index.html'),
- html: resolve(__dirname, 'src/html/index.html'),
- 'html-background': resolve(__dirname, 'src/html-background/index.html'),
- 'html-tailwind': resolve(__dirname, 'src/html-tailwind/index.html'),
- react: resolve(__dirname, 'src/react/index.html'),
- 'react-background': resolve(__dirname, 'src/react-background/index.html'),
- 'react-tailwind': resolve(__dirname, 'src/react-tailwind/index.html'),
+ 'html-video': resolve(__dirname, 'src/html-video/index.html'),
+ 'html-audio': resolve(__dirname, 'src/html-audio/index.html'),
+ 'html-video-tailwind': resolve(__dirname, 'src/html-video-tailwind/index.html'),
+ 'html-audio-tailwind': resolve(__dirname, 'src/html-audio-tailwind/index.html'),
+ 'html-background-video': resolve(__dirname, 'src/html-background-video/index.html'),
+ 'react-video': resolve(__dirname, 'src/react-video/index.html'),
+ 'react-audio': resolve(__dirname, 'src/react-audio/index.html'),
+ 'react-video-tailwind': resolve(__dirname, 'src/react-video-tailwind/index.html'),
+ 'react-audio-tailwind': resolve(__dirname, 'src/react-audio-tailwind/index.html'),
+ 'react-background-video': resolve(__dirname, 'src/react-background-video/index.html'),
},
},
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 80f96aef..fcf17b21 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -355,6 +355,9 @@ importers:
'@videojs/utils':
specifier: workspace:*
version: link:../utils
+ wouter:
+ specifier: ^3.0.0
+ version: 3.9.0(react@19.2.3)
devDependencies:
'@tailwindcss/vite':
specifier: ^4.2.0
@@ -5684,6 +5687,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
+ mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@@ -6268,6 +6274,10 @@ packages:
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
+ regexparam@3.0.0:
+ resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==}
+ engines: {node: '>=8'}
+
rehype-parse@9.0.1:
resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==}
@@ -7475,6 +7485,11 @@ packages:
resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==}
engines: {node: '>= 12.0.0'}
+ wouter@3.9.0:
+ resolution: {integrity: sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==}
+ peerDependencies:
+ react: '>=16.8.0'
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -10667,7 +10682,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -13409,6 +13424,8 @@ snapshots:
dependencies:
minipass: 7.1.2
+ mitt@3.0.1: {}
+
mlly@1.8.0:
dependencies:
acorn: 8.15.0
@@ -13935,6 +13952,8 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
+ regexparam@3.0.0: {}
+
rehype-parse@9.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -15242,6 +15261,13 @@ snapshots:
triple-beam: 1.4.1
winston-transport: 4.9.0
+ wouter@3.9.0(react@19.2.3):
+ dependencies:
+ mitt: 3.0.1
+ react: 19.2.3
+ regexparam: 3.0.0
+ use-sync-external-store: 1.6.0(react@19.2.3)
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0