feat(sandbox): rebuild sandbox with shell UI and expanded templates (#773)

This commit is contained in:
Sam Potts
2026-03-09 14:20:53 +11:00
committed by GitHub
parent d751fdabea
commit b4e01250b4
59 changed files with 1126 additions and 693 deletions
@@ -0,0 +1,13 @@
import type { Skin } from '../../types';
type SkinTagMap = Record<Skin, { video: string; audio: string }>;
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' },
};
@@ -0,0 +1,30 @@
import type { Skin } from '../../types';
const videoStylesheets: Record<Skin, string> = {
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<Skin, string> = {
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]);
}
@@ -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;
}
@@ -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,
});
@@ -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 <VideoSkinTailwind {...props} />;
case 'minimal':
return <MinimalVideoSkinTailwind {...props} />;
}
}
switch (skin) {
case 'default':
return <VideoSkin {...props} />;
case 'minimal':
return <MinimalVideoSkin {...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} />;
}
}
switch (skin) {
case 'default':
return <AudioSkin {...props} />;
case 'minimal':
return <MinimalAudioSkin {...props} />;
}
}
@@ -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;
}
@@ -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;
}
@@ -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<Skin>('local', 'skin', 'default');
const sourceStore = createWebStorageStore<SourceId>('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);
};
}
@@ -0,0 +1,36 @@
export type SourceId = 'hls-1' | 'hls-2' | 'hls-3' | 'hls-4' | 'mp4-1';
export const SOURCES: Record<SourceId, { label: string; url: string; type: 'hls' | 'mp4' }> = {
'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';