From c5ad81cf264390754e780ca52be6e3a8eeb87d33 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Wed, 18 Mar 2026 08:38:16 +1100 Subject: [PATCH] feat(sandbox): dynamically load skins by styling (#989) --- packages/sandbox/app/main.tsx | 11 -- .../sandbox/app/shared/html/sandbox-state.ts | 39 +++++++ packages/sandbox/app/shared/html/skins.ts | 67 ++++++++++++ .../sandbox/app/shared/html/stylesheets.ts | 10 +- .../sandbox/app/shared/html/tailwind-setup.ts | 14 +-- packages/sandbox/app/shared/react/skins.tsx | 102 ++++++++++++------ .../sandbox/app/shared/sandbox-listener.ts | 45 ++++---- packages/sandbox/app/shell/app.tsx | 41 +++---- packages/sandbox/app/shell/navbar.tsx | 4 +- packages/sandbox/app/shell/preview.tsx | 20 +++- .../app/utils/create-web-storage-store.ts | 50 --------- packages/sandbox/app/utils/stores.ts | 6 -- .../sandbox/app/utils/use-external-store.ts | 5 - .../sandbox/app/utils/use-skin-switcher.ts | 8 -- .../sandbox/app/utils/use-source-switcher.ts | 8 -- packages/sandbox/app/utils/use-web-storage.ts | 17 --- .../templates/html-audio-tailwind/index.html | 14 --- .../templates/html-audio-tailwind/main.ts | 43 -------- packages/sandbox/templates/html-audio/main.ts | 27 ++--- .../sandbox/templates/html-dash-video/main.ts | 27 ++--- .../sandbox/templates/html-hls-video/main.ts | 29 +++-- .../templates/html-simple-hls-video/main.ts | 29 +++-- .../templates/html-video-tailwind/index.html | 14 --- .../templates/html-video-tailwind/main.ts | 45 -------- packages/sandbox/templates/html-video/main.ts | 27 ++--- .../templates/react-audio-tailwind/index.html | 14 --- .../templates/react-audio-tailwind/main.tsx | 23 ---- .../sandbox/templates/react-audio/main.tsx | 11 +- .../templates/react-dash-video/main.tsx | 11 +- .../templates/react-hls-video/main.tsx | 11 +- .../templates/react-simple-hls-video/main.tsx | 11 +- .../templates/react-video-tailwind/index.html | 14 --- .../templates/react-video-tailwind/main.tsx | 28 ----- .../sandbox/templates/react-video/main.tsx | 11 +- 34 files changed, 338 insertions(+), 498 deletions(-) create mode 100644 packages/sandbox/app/shared/html/sandbox-state.ts create mode 100644 packages/sandbox/app/shared/html/skins.ts delete mode 100644 packages/sandbox/app/utils/create-web-storage-store.ts delete mode 100644 packages/sandbox/app/utils/stores.ts delete mode 100644 packages/sandbox/app/utils/use-external-store.ts delete mode 100644 packages/sandbox/app/utils/use-skin-switcher.ts delete mode 100644 packages/sandbox/app/utils/use-source-switcher.ts delete mode 100644 packages/sandbox/app/utils/use-web-storage.ts delete mode 100644 packages/sandbox/templates/html-audio-tailwind/index.html delete mode 100644 packages/sandbox/templates/html-audio-tailwind/main.ts delete mode 100644 packages/sandbox/templates/html-video-tailwind/index.html delete mode 100644 packages/sandbox/templates/html-video-tailwind/main.ts delete mode 100644 packages/sandbox/templates/react-audio-tailwind/index.html delete mode 100644 packages/sandbox/templates/react-audio-tailwind/main.tsx delete mode 100644 packages/sandbox/templates/react-video-tailwind/index.html delete mode 100644 packages/sandbox/templates/react-video-tailwind/main.tsx diff --git a/packages/sandbox/app/main.tsx b/packages/sandbox/app/main.tsx index 38834311..8aaf6380 100644 --- a/packages/sandbox/app/main.tsx +++ b/packages/sandbox/app/main.tsx @@ -1,16 +1,5 @@ -import type { SourceId } from '@app/shared/sources'; import '@app/styles.css'; import { App } from '@app/shell/app'; -import type { Skin } from '@app/types'; -import { skinStore, sourceStore } from '@app/utils/stores'; import { createRoot } from 'react-dom/client'; -// Sync query-param overrides to localStorage BEFORE React mounts, -// so useWebStorage hooks pick up the correct initial values. -const params = new URLSearchParams(location.search); -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); - createRoot(document.getElementById('root')!).render(); diff --git a/packages/sandbox/app/shared/html/sandbox-state.ts b/packages/sandbox/app/shared/html/sandbox-state.ts new file mode 100644 index 00000000..0906da5a --- /dev/null +++ b/packages/sandbox/app/shared/html/sandbox-state.ts @@ -0,0 +1,39 @@ +import { getInitialSkin, getInitialSource } from '@app/shared/sandbox-listener'; +import type { SourceId } from '@app/shared/sources'; +import type { Skin, Styling } from '@app/types'; + +function getInitialStyling(): Styling { + return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css'; +} + +export type HtmlSandboxState = { + skin: Skin; + source: SourceId; + styling: Styling; +}; + +export function createHtmlSandboxState(audioOnly?: boolean): HtmlSandboxState { + return { + skin: getInitialSkin(), + source: getInitialSource(audioOnly), + styling: getInitialStyling(), + }; +} + +export function createLatestLoader() { + let loadVersion = 0; + + return async (load: () => Promise): Promise => { + const version = ++loadVersion; + try { + const result = await load(); + return version === loadVersion ? result : undefined; + } catch (error) { + // Swallow load errors to avoid unhandled promise rejections in callers + // that do not await the returned promise. Callers can treat `undefined` + // as a signal that no valid result is available. + console.error('Failed to load latest result', error); + return undefined; + } + }; +} diff --git a/packages/sandbox/app/shared/html/skins.ts b/packages/sandbox/app/shared/html/skins.ts new file mode 100644 index 00000000..8b68e14c --- /dev/null +++ b/packages/sandbox/app/shared/html/skins.ts @@ -0,0 +1,67 @@ +import type { Skin, Styling } from '@app/types'; +import { CSS_SKIN_TAGS, TAILWIND_SKIN_TAGS } from './skin-tags'; +import { loadAudioStylesheets, loadVideoStylesheets } from './stylesheets'; + +async function loadVideoCssSkin(skin: Skin): Promise { + if (skin === 'default') { + await import('@videojs/html/video/skin'); + } else { + await import('@videojs/html/video/minimal-skin'); + } + + loadVideoStylesheets(skin); + + return CSS_SKIN_TAGS[skin].video; +} + +async function loadAudioCssSkin(skin: Skin): Promise { + if (skin === 'default') { + await import('@videojs/html/audio/skin'); + } else { + await import('@videojs/html/audio/minimal-skin'); + } + + loadAudioStylesheets(skin); + + return CSS_SKIN_TAGS[skin].audio; +} + +async function loadVideoTailwindSkin(skin: Skin): Promise { + if (skin === 'default') { + const { VideoSkinTailwindElement } = await import('@videojs/html/video/skin.tailwind'); + const { getTailwindStyles } = await import('./tailwind-setup'); + + VideoSkinTailwindElement.styles = getTailwindStyles(); + } else { + const { MinimalVideoSkinTailwindElement } = await import('@videojs/html/video/minimal-skin.tailwind'); + const { getTailwindStyles } = await import('./tailwind-setup'); + + MinimalVideoSkinTailwindElement.styles = getTailwindStyles(); + } + + return TAILWIND_SKIN_TAGS[skin].video; +} + +async function loadAudioTailwindSkin(skin: Skin): Promise { + if (skin === 'default') { + const { AudioSkinTailwindElement } = await import('@videojs/html/audio/skin.tailwind'); + const { getTailwindStyles } = await import('./tailwind-setup'); + + AudioSkinTailwindElement.styles = getTailwindStyles(); + } else { + const { MinimalAudioSkinTailwindElement } = await import('@videojs/html/audio/minimal-skin.tailwind'); + const { getTailwindStyles } = await import('./tailwind-setup'); + + MinimalAudioSkinTailwindElement.styles = getTailwindStyles(); + } + + return TAILWIND_SKIN_TAGS[skin].audio; +} + +export function loadVideoSkinTag(skin: Skin, styling: Styling): Promise { + return styling === 'tailwind' ? loadVideoTailwindSkin(skin) : loadVideoCssSkin(skin); +} + +export function loadAudioSkinTag(skin: Skin, styling: Styling): Promise { + return styling === 'tailwind' ? loadAudioTailwindSkin(skin) : loadAudioCssSkin(skin); +} diff --git a/packages/sandbox/app/shared/html/stylesheets.ts b/packages/sandbox/app/shared/html/stylesheets.ts index c4563643..c89fdeee 100644 --- a/packages/sandbox/app/shared/html/stylesheets.ts +++ b/packages/sandbox/app/shared/html/stylesheets.ts @@ -10,21 +10,21 @@ const audioStylesheets: Record = { 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}"]`); +function loadStylesheet(id: string, url: string) { + const existing = document.querySelector(`link[rel="stylesheet"][data-sandbox-stylesheet="${id}"]`); existing?.remove(); const link = document.createElement('link'); + link.dataset.sandboxStylesheet = id; link.rel = 'stylesheet'; link.href = url; document.head.appendChild(link); } export function loadVideoStylesheets(skin: Skin) { - loadStylesheet(videoStylesheets[skin]); + loadStylesheet('video-skin', videoStylesheets[skin]); } export function loadAudioStylesheets(skin: Skin) { - loadStylesheet(audioStylesheets[skin]); + loadStylesheet('audio-skin', audioStylesheets[skin]); } diff --git a/packages/sandbox/app/shared/html/tailwind-setup.ts b/packages/sandbox/app/shared/html/tailwind-setup.ts index 9c0336a8..1774c06d 100644 --- a/packages/sandbox/app/shared/html/tailwind-setup.ts +++ b/packages/sandbox/app/shared/html/tailwind-setup.ts @@ -1,18 +1,8 @@ import tailwindCSS from '@app/styles.css?inline'; -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'; 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; +export function getTailwindStyles(): CSSStyleSheet { + return tailwindStyles; } diff --git a/packages/sandbox/app/shared/react/skins.tsx b/packages/sandbox/app/shared/react/skins.tsx index 2314371a..cf8c2cf3 100644 --- a/packages/sandbox/app/shared/react/skins.tsx +++ b/packages/sandbox/app/shared/react/skins.tsx @@ -1,45 +1,87 @@ import type { Skin, Styling } from '@app/types'; 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 { ComponentType } from 'react'; +import { useEffect, useState } from 'react'; + +async function loadVideoSkinComponent(skin: Skin, styling: Styling): Promise> { + const module = await import('@videojs/react/video'); + + if (styling === 'tailwind') { + return skin === 'default' ? module.VideoSkinTailwind : module.MinimalVideoSkinTailwind; + } + + if (skin === 'default') { + await import('@videojs/react/video/skin.css'); + return module.VideoSkin; + } + + await import('@videojs/react/video/minimal-skin.css'); + return module.MinimalVideoSkin; +} + +async function loadAudioSkinComponent(skin: Skin, styling: Styling): Promise> { + const module = await import('@videojs/react/audio'); + + if (styling === 'tailwind') { + return skin === 'default' ? module.AudioSkinTailwind : module.MinimalAudioSkinTailwind; + } + + if (skin === 'default') { + await import('@videojs/react/audio/skin.css'); + return module.AudioSkin; + } + + await import('@videojs/react/audio/minimal-skin.css'); + return module.MinimalAudioSkin; +} + +function useLoadedComponent( + load: () => Promise>, + deps: readonly unknown[] +): ComponentType | null { + const [component, setComponent] = useState | null>(null); + + useEffect(() => { + let active = true; + + void load() + .then((resolved) => { + if (!active) return; + + setComponent(() => resolved); + }) + .catch(() => { + if (!active) return; + // Intentionally ignore load errors to avoid unhandled promise rejections. + // The component will remain null, and callers can handle absence as needed. + }); + + return () => { + active = false; + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: we're proxying the deps + }, deps); + + return component; +} 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 ; - } - } + const Component = useLoadedComponent(() => loadVideoSkinComponent(skin, styling), [skin, styling]); - switch (skin) { - case 'default': - return ; - case 'minimal': - return ; - } + if (!Component) return null; + + 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 ; - } - } + const Component = useLoadedComponent(() => loadAudioSkinComponent(skin, styling), [skin, styling]); - switch (skin) { - case 'default': - return ; - case 'minimal': - return ; - } + if (!Component) return null; + + return ; } diff --git a/packages/sandbox/app/shared/sandbox-listener.ts b/packages/sandbox/app/shared/sandbox-listener.ts index 8ec0b86a..b2d38aa9 100644 --- a/packages/sandbox/app/shared/sandbox-listener.ts +++ b/packages/sandbox/app/shared/sandbox-listener.ts @@ -1,42 +1,45 @@ +import { SKINS } from '@app/constants'; import type { Skin } from '@app/types'; -import { createWebStorageStore } from '@app/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'); +function readSkin(): Skin { + const skin = params.get('skin'); -// Apply query param overrides to localStorage so they persist -const skinParam = params.get('skin') as Skin | null; -if (skinParam) skinStore.setValue(skinParam); + return skin && SKINS.includes(skin as Skin) ? (skin as Skin) : 'default'; +} -const sourceParam = params.get('source') as SourceId | null; -if (sourceParam) sourceStore.setValue(sourceParam); +function readSource(): SourceId { + const source = params.get('source'); + + return source && source in SOURCES ? (source as SourceId) : 'hls-1'; +} + +let currentSkin = readSkin(); +let currentSource = readSource(); export function getInitialSkin(): Skin { - return skinStore.getSnapshot(); + return currentSkin; } 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); - } + if (event.data?.type !== 'skin-change' || !SKINS.includes(event.data.skin)) return; + + currentSkin = event.data.skin; + callback(currentSkin); }; window.addEventListener('message', handler); return () => { - unsubStore(); window.removeEventListener('message', handler); }; } export function getInitialSource(audioOnly?: boolean): SourceId { - const stored = sourceStore.getSnapshot(); + const stored = currentSource; if (audioOnly && SOURCES[stored].type !== 'mp4') { return DEFAULT_AUDIO_SOURCE; @@ -46,18 +49,16 @@ export function getInitialSource(audioOnly?: boolean): SourceId { } 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); - } + if (event.data?.type !== 'source-change' || !(event.data.source in SOURCES)) return; + + currentSource = event.data.source; + callback(currentSource); }; window.addEventListener('message', handler); return () => { - unsubStore(); window.removeEventListener('message', handler); }; } diff --git a/packages/sandbox/app/shell/app.tsx b/packages/sandbox/app/shell/app.tsx index 1fc2b2f7..4f91ac78 100644 --- a/packages/sandbox/app/shell/app.tsx +++ b/packages/sandbox/app/shell/app.tsx @@ -9,15 +9,12 @@ import { SOURCES, } from '@app/shared/sources'; import type { Platform, Preset, Styling } from '@app/types'; -import { useSkinSwitcher } from '@app/utils/use-skin-switcher'; -import { useSourceSwitcher } from '@app/utils/use-source-switcher'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Navbar } from './navbar'; import { Preview } from './preview'; -function getPagePath(platform: Platform, styling: Styling, preset: Preset): string { +function getPagePath(platform: Platform, preset: Preset): string { if (preset === 'background-video') return `/${platform}-background-video/`; - if (styling === 'tailwind') return `/${platform}-${preset}-tailwind/`; return `/${platform}-${preset}/`; } @@ -27,19 +24,21 @@ function readParams() { platform: (params.get('platform') ?? 'html') as Platform, styling: (params.get('styling') ?? 'css') as Styling, preset: (params.get('preset') ?? 'video') as Preset, + skin: (params.get('skin') ?? 'default') as 'default' | 'minimal', + source: (params.get('source') ?? 'hls-1') as SourceId, }; } export function App() { const initial = useMemo(readParams, []); const [platform, setPlatform] = useState(initial.platform); - const [styling, setStyling] = useState(initial.styling); + const [styling, setStyling] = useState(initial.styling); const [preset, setPreset] = useState(initial.preset); - const [skin, setSkin] = useSkinSwitcher(); - const [source, setSource] = useSourceSwitcher(); + const [skin, setSkin] = useState(initial.skin); + const [source, setSource] = useState(initial.source); const iframeRef = useRef(null); - const pagePath = getPagePath(platform, styling, preset); + const pagePath = getPagePath(platform, preset); // Keep the URL in sync with all state (including skin + source) useEffect(() => { @@ -47,23 +46,11 @@ export function App() { history.replaceState(null, '', `/?${params}`); }, [platform, styling, preset, skin, source]); - // Send postMessage to iframe for skin changes (skip initial mount — iframe reads localStorage) - const skinMountRef = useRef(true); useEffect(() => { - if (skinMountRef.current) { - skinMountRef.current = false; - return; - } iframeRef.current?.contentWindow?.postMessage({ type: 'skin-change', skin }, '*'); }, [skin]); - // Send postMessage to iframe for source changes (skip initial mount — iframe reads localStorage) - const sourceMountRef = useRef(true); useEffect(() => { - if (sourceMountRef.current) { - sourceMountRef.current = false; - return; - } iframeRef.current?.contentWindow?.postMessage({ type: 'source-change', source }, '*'); }, [source]); @@ -81,9 +68,9 @@ export function App() { } }, [preset, source, setSource]); - // Constrain styling when switching to a preset that has no tailwind template + // Background video does not have a Tailwind skin variant. useEffect(() => { - if ((preset === 'background-video' || preset === 'dash-video') && styling === 'tailwind') { + if (preset === 'background-video' && styling === 'tailwind') { setStyling('css'); } }, [preset, styling]); @@ -108,13 +95,19 @@ export function App() { availableSources={availableSources} isBackgroundVideo={preset === 'background-video'} isSimpleHlsVideo={preset === 'simple-hls-video'} - isDashVideo={preset === 'dash-video'} platforms={PLATFORMS} stylings={STYLINGS} presets={PRESETS} sources={SOURCES} /> - + ); } diff --git a/packages/sandbox/app/shell/navbar.tsx b/packages/sandbox/app/shell/navbar.tsx index 36b11970..9b0fd1a0 100644 --- a/packages/sandbox/app/shell/navbar.tsx +++ b/packages/sandbox/app/shell/navbar.tsx @@ -16,7 +16,6 @@ type NavbarProps = { availableSources: readonly SourceId[]; isBackgroundVideo: boolean; isSimpleHlsVideo: boolean; - isDashVideo: boolean; platforms: readonly Platform[]; stylings: readonly Styling[]; presets: readonly Preset[]; @@ -53,7 +52,6 @@ export function Navbar({ availableSources, isBackgroundVideo, isSimpleHlsVideo, - isDashVideo, platforms, stylings, presets, @@ -82,7 +80,7 @@ export function Navbar({ options={stylings.map((s) => ({ value: s, label: s === 'css' ? 'CSS' : 'Tailwind', - disabled: s === 'tailwind' && (isBackgroundVideo || isDashVideo), + disabled: s === 'tailwind' && isBackgroundVideo, }))} /> diff --git a/packages/sandbox/app/shell/preview.tsx b/packages/sandbox/app/shell/preview.tsx index 19f755e1..9442ed16 100644 --- a/packages/sandbox/app/shell/preview.tsx +++ b/packages/sandbox/app/shell/preview.tsx @@ -1,15 +1,25 @@ import type { SourceId } from '@app/shared/sources'; -import type { Skin } from '@app/types'; -import { forwardRef } from 'react'; +import type { Skin, Styling } from '@app/types'; +import { forwardRef, useState } from 'react'; type PreviewProps = { pagePath: string; skin: Skin; + styling: Styling; source: SourceId; }; -export const Preview = forwardRef(function Preview({ pagePath, skin, source }, ref) { - const openUrl = `${pagePath}?skin=${encodeURIComponent(skin)}&source=${encodeURIComponent(source)}`; +export const Preview = forwardRef(function Preview( + { pagePath, skin, styling, source }, + ref +) { + const [iframeUrl] = useState( + () => + `${pagePath}?skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}&source=${encodeURIComponent(source)}` + ); + const openUrl = + `${pagePath}?skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}` + + `&source=${encodeURIComponent(source)}`; return (
@@ -37,7 +47,7 @@ export const Preview = forwardRef(function Prev -