feat(sandbox): dynamically load skins by styling (#989)

This commit is contained in:
Sam Potts
2026-03-18 08:38:16 +11:00
committed by GitHub
parent fa54409824
commit c5ad81cf26
34 changed files with 338 additions and 498 deletions
-11
View File
@@ -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(<App />);
@@ -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 <Result>(load: () => Promise<Result>): Promise<Result | undefined> => {
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;
}
};
}
+67
View File
@@ -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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
return styling === 'tailwind' ? loadVideoTailwindSkin(skin) : loadVideoCssSkin(skin);
}
export function loadAudioSkinTag(skin: Skin, styling: Styling): Promise<string> {
return styling === 'tailwind' ? loadAudioTailwindSkin(skin) : loadAudioCssSkin(skin);
}
@@ -10,21 +10,21 @@ const audioStylesheets: Record<Skin, string> = {
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]);
}
@@ -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;
}
+72 -30
View File
@@ -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<ComponentType<VideoSkinProps>> {
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<ComponentType<AudioSkinProps>> {
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<Props>(
load: () => Promise<ComponentType<Props>>,
deps: readonly unknown[]
): ComponentType<Props> | null {
const [component, setComponent] = useState<ComponentType<Props> | 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 <VideoSkinTailwind {...props} />;
case 'minimal':
return <MinimalVideoSkinTailwind {...props} />;
}
}
const Component = useLoadedComponent(() => loadVideoSkinComponent(skin, styling), [skin, styling]);
switch (skin) {
case 'default':
return <VideoSkin {...props} />;
case 'minimal':
return <MinimalVideoSkin {...props} />;
}
if (!Component) return null;
return <Component {...props} />;
}
type AudioSkinComponentProps = { skin: Skin; styling: Styling } & AudioSkinProps;
export function AudioSkinComponent({ skin, styling, ...props }: AudioSkinComponentProps) {
if (styling === 'tailwind') {
switch (skin) {
case 'default':
return <AudioSkinTailwind {...props} />;
case 'minimal':
return <MinimalAudioSkinTailwind {...props} />;
}
}
const Component = useLoadedComponent(() => loadAudioSkinComponent(skin, styling), [skin, styling]);
switch (skin) {
case 'default':
return <AudioSkin {...props} />;
case 'minimal':
return <MinimalAudioSkin {...props} />;
}
if (!Component) return null;
return <Component {...props} />;
}
+23 -22
View File
@@ -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<Skin>('local', 'skin', 'default');
const sourceStore = createWebStorageStore<SourceId>('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);
};
}
+17 -24
View File
@@ -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<Platform>(initial.platform);
const [styling, setStyling] = useState<Styling>(initial.styling);
const [styling, setStyling] = useState(initial.styling);
const [preset, setPreset] = useState<Preset>(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<HTMLIFrameElement>(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}
/>
<Preview ref={iframeRef} pagePath={pagePath} skin={skin} source={source} />
<Preview
key={`${pagePath}:${styling}`}
ref={iframeRef}
pagePath={pagePath}
skin={skin}
styling={styling}
source={source}
/>
</div>
);
}
+1 -3
View File
@@ -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,
}))}
/>
+15 -5
View File
@@ -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<HTMLIFrameElement, PreviewProps>(function Preview({ pagePath, skin, source }, ref) {
const openUrl = `${pagePath}?skin=${encodeURIComponent(skin)}&source=${encodeURIComponent(source)}`;
export const Preview = forwardRef<HTMLIFrameElement, PreviewProps>(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 (
<main className="flex-1 min-h-0 relative bg-zinc-50 dark:bg-zinc-900">
@@ -37,7 +47,7 @@ export const Preview = forwardRef<HTMLIFrameElement, PreviewProps>(function Prev
<line x1="10" x2="21" y1="14" y2="3" />
</svg>
</a>
<iframe ref={ref} src={pagePath} className="absolute inset-0 w-full h-full border-0" title="player demo" />
<iframe ref={ref} src={iframeUrl} className="absolute inset-0 w-full h-full border-0" title="player demo" />
</main>
);
});
@@ -1,50 +0,0 @@
export type WebStorageType = 'local' | 'session';
export type WebStorageSerializableValue =
| string
| boolean
| number
| null
| Record<string, any>
| WebStorageSerializableValue[];
type Subscriber = () => void;
export function createWebStorageStore<T extends WebStorageSerializableValue>(
type: WebStorageType,
key: string,
initialValue: T
) {
const listeners = new Set<Subscriber>();
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 };
}
-6
View File
@@ -1,6 +0,0 @@
import type { SourceId } from '@app/shared/sources';
import type { Skin } from '@app/types';
import { createWebStorageStore } from './create-web-storage-store';
export const skinStore = createWebStorageStore<Skin>('local', 'skin', 'default');
export const sourceStore = createWebStorageStore<SourceId>('local', 'source', 'hls-1');
@@ -1,5 +0,0 @@
import { useSyncExternalStore } from 'react';
export function useExternalStore<T>(store: { subscribe: (listener: () => void) => () => void; getSnapshot: () => T }) {
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
}
@@ -1,8 +0,0 @@
import type { Skin } from '@app/types';
import { skinStore } from './stores';
import { useExternalStore } from './use-external-store';
export function useSkinSwitcher(): [Skin, (value: Skin) => void] {
const value = useExternalStore(skinStore);
return [value, skinStore.setValue];
}
@@ -1,8 +0,0 @@
import type { SourceId } from '@app/shared/sources';
import { sourceStore } from './stores';
import { useExternalStore } from './use-external-store';
export function useSourceSwitcher(): [SourceId, (value: SourceId) => void] {
const value = useExternalStore(sourceStore);
return [value, sourceStore.setValue];
}
@@ -1,17 +0,0 @@
import { useState } from 'react';
import {
createWebStorageStore,
type WebStorageSerializableValue,
type WebStorageType,
} from './create-web-storage-store';
import { useExternalStore } from './use-external-store';
export function useWebStorage<T extends WebStorageSerializableValue>(
type: WebStorageType,
key: string,
defaultValue: T
): [T, (value: T) => void] {
const [store] = useState(() => createWebStorageStore<T>(type, key, defaultValue));
const value = useExternalStore(store);
return [value, store.setValue];
}
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — HTML Audio Tailwind</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="font-sans">
<div id="root" class="flex justify-center items-center min-h-screen"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -1,43 +0,0 @@
import '@app/styles.css';
import '@videojs/html/audio/player';
import '@videojs/html/audio/skin.tailwind';
import '@videojs/html/audio/minimal-skin.tailwind';
import { TAILWIND_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { setupAudioTailwind } from '@app/shared/html/tailwind-setup';
import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import type { SourceId } from '@app/shared/sources';
import { SOURCES } from '@app/shared/sources';
import type { Skin } from '@app/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`
<div class="w-full max-w-xl mx-auto">
<audio-player>
<${tag}>
<audio slot="media" src="${SOURCES[currentSource].url}"></audio>
</${tag}>
</audio-player>
</div>
`;
}
render();
onSkinChange((skin) => {
currentSkin = skin;
render();
});
onSourceChange((source) => {
currentSource = source;
render();
});
+11 -16
View File
@@ -1,29 +1,24 @@
import '@app/styles.css';
import '@videojs/html/audio/player';
import '@videojs/html/audio/skin';
import '@videojs/html/audio/minimal-skin';
import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { loadAudioStylesheets } from '@app/shared/html/stylesheets';
import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import type { SourceId } from '@app/shared/sources';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadAudioSkinTag } from '@app/shared/html/skins';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { SOURCES } from '@app/shared/sources';
import type { Skin } from '@app/types';
const html = String.raw;
let currentSkin: Skin = getInitialSkin();
let currentSource: SourceId = getInitialSource(true);
const state = createHtmlSandboxState(true);
const loadLatest = createLatestLoader();
function render() {
const tag = CSS_SKIN_TAGS[currentSkin].audio;
loadAudioStylesheets(currentSkin);
async function render() {
const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling));
if (!tag) return;
document.getElementById('root')!.innerHTML = html`
<div class="w-full max-w-xl mx-auto">
<audio-player>
<${tag}>
<audio slot="media" src="${SOURCES[currentSource].url}"></audio>
<audio slot="media" src="${SOURCES[state.source].url}"></audio>
</${tag}>
</audio-player>
</div>
@@ -33,11 +28,11 @@ function render() {
render();
onSkinChange((skin) => {
currentSkin = skin;
state.skin = skin;
render();
});
onSourceChange((source) => {
currentSource = source;
state.source = source;
render();
});
@@ -1,29 +1,24 @@
import '@app/styles.css';
import '@videojs/html/video/player';
import '@videojs/html/media/dash-video';
import '@videojs/html/video/skin';
import '@videojs/html/video/minimal-skin';
import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { loadVideoStylesheets } from '@app/shared/html/stylesheets';
import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import type { SourceId } from '@app/shared/sources';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { SOURCES } from '@app/shared/sources';
import type { Skin } from '@app/types';
const html = String.raw;
let currentSkin: Skin = getInitialSkin();
let currentSource: SourceId = getInitialSource();
const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();
function render() {
const tag = CSS_SKIN_TAGS[currentSkin].video;
loadVideoStylesheets(currentSkin);
async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;
document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
<dash-video slot="media" src="${SOURCES[currentSource].url}" playsinline></dash-video>
<dash-video slot="media" src="${SOURCES[state.source].url}" playsinline></dash-video>
</${tag}>
</video-player>
`;
@@ -32,11 +27,11 @@ function render() {
render();
onSkinChange((skin) => {
currentSkin = skin;
state.skin = skin;
render();
});
onSourceChange((source) => {
currentSource = source;
state.source = source;
render();
});
@@ -1,31 +1,26 @@
import '@app/styles.css';
import '@videojs/html/video/player';
import '@videojs/html/media/hls-video';
import '@videojs/html/video/skin';
import '@videojs/html/video/minimal-skin';
import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard';
import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { loadVideoStylesheets } from '@app/shared/html/stylesheets';
import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import type { SourceId } from '@app/shared/sources';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { SOURCES } from '@app/shared/sources';
import type { Skin } from '@app/types';
const html = String.raw;
let currentSkin: Skin = getInitialSkin();
let currentSource: SourceId = getInitialSource();
const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();
function render() {
const tag = CSS_SKIN_TAGS[currentSkin].video;
loadVideoStylesheets(currentSkin);
async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;
document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
<hls-video slot="media" src="${SOURCES[currentSource].url}" playsinline crossorigin="anonymous">
${renderMuxStoryboard(currentSource)}
<hls-video slot="media" src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
${renderMuxStoryboard(state.source)}
</hls-video>
</${tag}>
</video-player>
@@ -35,11 +30,11 @@ function render() {
render();
onSkinChange((skin) => {
currentSkin = skin;
state.skin = skin;
render();
});
onSourceChange((source) => {
currentSource = source;
state.source = source;
render();
});
@@ -1,31 +1,26 @@
import '@app/styles.css';
import '@videojs/html/video/player';
import '@videojs/html/media/simple-hls-video';
import '@videojs/html/video/skin';
import '@videojs/html/video/minimal-skin';
import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard';
import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { loadVideoStylesheets } from '@app/shared/html/stylesheets';
import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import type { SourceId } from '@app/shared/sources';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { loadVideoSkinTag } from '@app/shared/html/skins';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { SOURCES } from '@app/shared/sources';
import type { Skin } from '@app/types';
const html = String.raw;
let currentSkin: Skin = getInitialSkin();
let currentSource: SourceId = getInitialSource();
const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();
function render() {
const tag = CSS_SKIN_TAGS[currentSkin].video;
loadVideoStylesheets(currentSkin);
async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;
document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
<simple-hls-video slot="media" src="${SOURCES[currentSource].url}" playsinline crossorigin="anonymous">
${renderMuxStoryboard(currentSource)}
<simple-hls-video slot="media" src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
${renderMuxStoryboard(state.source)}
</simple-hls-video>
</${tag}>
</video-player>
@@ -35,11 +30,11 @@ function render() {
render();
onSkinChange((skin) => {
currentSkin = skin;
state.skin = skin;
render();
});
onSourceChange((source) => {
currentSource = source;
state.source = source;
render();
});
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — HTML Video Tailwind</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="font-sans">
<div id="root" class="flex justify-center items-center min-h-screen"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -1,45 +0,0 @@
import '@app/styles.css';
import '@videojs/html/ui/poster';
import '@videojs/html/video/player';
import '@videojs/html/video/skin.tailwind';
import '@videojs/html/video/minimal-skin.tailwind';
import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard';
import { TAILWIND_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { setupVideoTailwind } from '@app/shared/html/tailwind-setup';
import { getInitialSkin, getInitialSource, onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import type { SourceId } from '@app/shared/sources';
import { SOURCES } from '@app/shared/sources';
import type { Skin } from '@app/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`
<video-player>
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
<video slot="media" src="${SOURCES[currentSource].url}" playsinline crossorigin="anonymous">
${renderMuxStoryboard(currentSource)}
</video>
</${tag}>
</video-player>
`;
}
render();
onSkinChange((skin) => {
currentSkin = skin;
render();
});
onSourceChange((source) => {
currentSource = source;
render();
});
+11 -16
View File
@@ -1,31 +1,26 @@
import '@app/styles.css';
import '@videojs/html/video/player';
import '@videojs/html/video/skin';
import '@videojs/html/video/minimal-skin';
import '@videojs/html/ui/poster';
import { renderMuxStoryboard } from '@app/shared/html/mux-storyboard';
import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { loadVideoStylesheets } from '@app/shared/html/stylesheets';
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 type { SourceId } from '@app/shared/sources';
import { SOURCES } from '@app/shared/sources';
import type { Skin } from '@app/types';
const html = String.raw;
let currentSkin: Skin = getInitialSkin();
let currentSource: SourceId = getInitialSource();
const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();
function render() {
const tag = CSS_SKIN_TAGS[currentSkin].video;
loadVideoStylesheets(currentSkin);
async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
if (!tag) return;
document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
<video slot="media" src="${SOURCES[currentSource].url}" playsinline crossorigin="anonymous">
${renderMuxStoryboard(currentSource)}
<video slot="media" src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
${renderMuxStoryboard(state.source)}
</video>
</${tag}>
</video-player>
@@ -35,11 +30,11 @@ function render() {
render();
onSkinChange((skin) => {
currentSkin = skin;
state.skin = skin;
render();
});
onSourceChange((source) => {
currentSource = source;
state.source = source;
render();
});
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — React Audio Tailwind</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="font-sans">
<div id="root" class="flex justify-center items-center min-h-screen"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
@@ -1,23 +0,0 @@
import '@app/styles.css';
import { AudioProvider } from '@app/shared/react/providers';
import { AudioSkinComponent } from '@app/shared/react/skins';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { SOURCES } from '@app/shared/sources';
import { Audio } from '@videojs/react/audio';
import { createRoot } from 'react-dom/client';
function App() {
const skin = useSkin();
const source = useSource(true);
return (
<AudioProvider>
<AudioSkinComponent skin={skin} styling="tailwind" className="w-full max-w-xl mx-auto">
<Audio src={SOURCES[source].url} />
</AudioSkinComponent>
</AudioProvider>
);
}
createRoot(document.getElementById('root')!).render(<App />);
@@ -1,21 +1,26 @@
import '@app/styles.css';
import '@videojs/react/audio/skin.css';
import '@videojs/react/audio/minimal-skin.css';
import { AudioProvider } from '@app/shared/react/providers';
import { AudioSkinComponent } from '@app/shared/react/skins';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { Audio } from '@videojs/react/audio';
import { useMemo } from 'react';
import { createRoot } from 'react-dom/client';
function readStyling(): Styling {
return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css';
}
function App() {
const skin = useSkin();
const source = useSource(true);
const styling = useMemo(readStyling, []);
return (
<AudioProvider>
<AudioSkinComponent skin={skin} styling="css" className="w-full max-w-xl mx-auto">
<AudioSkinComponent skin={skin} styling={styling} className="w-full max-w-xl mx-auto">
<Audio src={SOURCES[source].url} />
</AudioSkinComponent>
</AudioProvider>
@@ -1,21 +1,26 @@
import '@app/styles.css';
import '@videojs/react/video/skin.css';
import '@videojs/react/video/minimal-skin.css';
import { VideoProvider } from '@app/shared/react/providers';
import { VideoSkinComponent } from '@app/shared/react/skins';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { DashVideo } from '@videojs/react/media/dash-video';
import { useMemo } from 'react';
import { createRoot } from 'react-dom/client';
function readStyling(): Styling {
return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css';
}
function App() {
const skin = useSkin();
const source = useSource();
const styling = useMemo(readStyling, []);
return (
<VideoProvider>
<VideoSkinComponent skin={skin} styling="css" className="w-full aspect-video max-w-4xl mx-auto">
<VideoSkinComponent skin={skin} styling={styling} className="w-full aspect-video max-w-4xl mx-auto">
<DashVideo src={SOURCES[source].url} playsInline />
</VideoSkinComponent>
</VideoProvider>
@@ -1,6 +1,4 @@
import '@app/styles.css';
import '@videojs/react/video/skin.css';
import '@videojs/react/video/minimal-skin.css';
import { MuxPoster } from '@app/shared/react/mux-poster';
import { MuxStoryboard } from '@app/shared/react/mux-storyboard';
import { VideoProvider } from '@app/shared/react/providers';
@@ -8,16 +6,23 @@ import { VideoSkinComponent } from '@app/shared/react/skins';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { HlsVideo } from '@videojs/react/media/hls-video';
import { useMemo } from 'react';
import { createRoot } from 'react-dom/client';
function readStyling(): Styling {
return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css';
}
function App() {
const skin = useSkin();
const source = useSource();
const styling = useMemo(readStyling, []);
return (
<VideoProvider>
<VideoSkinComponent skin={skin} styling="css" className="w-full aspect-video max-w-4xl mx-auto">
<VideoSkinComponent skin={skin} styling={styling} className="w-full aspect-video max-w-4xl mx-auto">
<HlsVideo src={SOURCES[source].url} playsInline crossOrigin="anonymous">
<MuxStoryboard source={source} />
</HlsVideo>
@@ -1,6 +1,4 @@
import '@app/styles.css';
import '@videojs/react/video/skin.css';
import '@videojs/react/video/minimal-skin.css';
import { MuxPoster } from '@app/shared/react/mux-poster';
import { MuxStoryboard } from '@app/shared/react/mux-storyboard';
import { VideoProvider } from '@app/shared/react/providers';
@@ -8,16 +6,23 @@ import { VideoSkinComponent } from '@app/shared/react/skins';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { SimpleHlsVideo } from '@videojs/react/media/simple-hls-video';
import { useMemo } from 'react';
import { createRoot } from 'react-dom/client';
function readStyling(): Styling {
return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css';
}
function App() {
const skin = useSkin();
const source = useSource();
const styling = useMemo(readStyling, []);
return (
<VideoProvider>
<VideoSkinComponent skin={skin} styling="css" className="w-full aspect-video max-w-4xl mx-auto">
<VideoSkinComponent skin={skin} styling={styling} className="w-full aspect-video max-w-4xl mx-auto">
<SimpleHlsVideo src={SOURCES[source].url} playsInline crossOrigin="anonymous">
<MuxStoryboard source={source} />
</SimpleHlsVideo>
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox — React Video Tailwind</title>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
</head>
<body class="font-sans">
<div id="root" class="flex justify-center items-center min-h-screen"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
@@ -1,28 +0,0 @@
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 { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { SOURCES } from '@app/shared/sources';
import { Video } from '@videojs/react/video';
import { createRoot } from 'react-dom/client';
function App() {
const skin = useSkin();
const source = useSource();
return (
<VideoProvider>
<VideoSkinComponent skin={skin} styling="tailwind" className="w-full aspect-video max-w-4xl mx-auto">
<Video src={SOURCES[source].url} playsInline crossOrigin="anonymous">
<MuxStoryboard source={source} />
</Video>
<MuxPoster source={source} />
</VideoSkinComponent>
</VideoProvider>
);
}
createRoot(document.getElementById('root')!).render(<App />);
@@ -1,6 +1,4 @@
import '@app/styles.css';
import '@videojs/react/video/skin.css';
import '@videojs/react/video/minimal-skin.css';
import { MuxPoster } from '@app/shared/react/mux-poster';
import { MuxStoryboard } from '@app/shared/react/mux-storyboard';
import { VideoProvider } from '@app/shared/react/providers';
@@ -8,16 +6,23 @@ import { VideoSkinComponent } from '@app/shared/react/skins';
import { useSkin } from '@app/shared/react/use-skin';
import { useSource } from '@app/shared/react/use-source';
import { SOURCES } from '@app/shared/sources';
import type { Styling } from '@app/types';
import { Video } from '@videojs/react/video';
import { useMemo } from 'react';
import { createRoot } from 'react-dom/client';
function readStyling(): Styling {
return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css';
}
function App() {
const skin = useSkin();
const source = useSource();
const styling = useMemo(readStyling, []);
return (
<VideoProvider>
<VideoSkinComponent skin={skin} styling="css" className="w-full aspect-video max-w-4xl mx-auto">
<VideoSkinComponent skin={skin} styling={styling} className="w-full aspect-video max-w-4xl mx-auto">
<Video src={SOURCES[source].url} playsInline crossOrigin="anonymous">
<MuxStoryboard source={source} />
</Video>