chore: move sandbox to apps/ (#1171)

This commit is contained in:
rahim
2026-04-01 12:42:06 -07:00
committed by GitHub
parent 2fff955ab6
commit 6c5f8c0eda
77 changed files with 94 additions and 84 deletions
@@ -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;
}
};
}
+13
View File
@@ -0,0 +1,13 @@
import type { Skin } from '@app/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' },
};
+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);
}
@@ -0,0 +1,3 @@
export function renderStoryboard(src?: string | undefined): string {
return src ? `<track kind="metadata" label="thumbnails" src="${src}" default />` : '';
}
@@ -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(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('video-skin', videoStylesheets[skin]);
}
export function loadAudioStylesheets(skin: Skin) {
loadStylesheet('audio-skin', audioStylesheets[skin]);
}
@@ -0,0 +1,8 @@
import tailwindCSS from '@app/styles.css?inline';
const tailwindStyles = new CSSStyleSheet();
tailwindStyles.replaceSync(tailwindCSS);
export function getTailwindStyles(): CSSStyleSheet {
return tailwindStyles;
}
+5
View File
@@ -0,0 +1,5 @@
import { SOURCES, type SourceId } from './sources';
export function getMuxAssetId(source: SourceId): string | undefined {
return SOURCES[source].url.match(/stream\.mux\.com\/([a-zA-Z0-9]+)/)?.[1];
}
@@ -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,
});
+87
View File
@@ -0,0 +1,87 @@
import type { Skin, Styling } from '@app/types';
import type { AudioSkinProps } from '@videojs/react/audio';
import type { VideoSkinProps } 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) {
const Component = useLoadedComponent(() => loadVideoSkinComponent(skin, styling), [skin, styling]);
if (!Component) return null;
return <Component {...props} />;
}
type AudioSkinComponentProps = { skin: Skin; styling: Styling } & AudioSkinProps;
export function AudioSkinComponent({ skin, styling, ...props }: AudioSkinComponentProps) {
const Component = useLoadedComponent(() => loadAudioSkinComponent(skin, styling), [skin, styling]);
if (!Component) return null;
return <Component {...props} />;
}
@@ -0,0 +1,8 @@
type StoryboardProps = {
src?: string | undefined;
};
export function Storyboard({ src }: StoryboardProps) {
if (!src) return null;
return <track kind="metadata" label="thumbnails" src={src} default />;
}
@@ -0,0 +1,8 @@
import { useMemo } from 'react';
import { getPosterSrc } from '../sources';
import { useSource } from './use-source';
export function usePoster() {
const source = useSource();
return useMemo(() => getPosterSrc(source), [source]);
}
@@ -0,0 +1,9 @@
import { getInitialSkin, onSkinChange } from '@app/shared/sandbox-listener';
import type { Skin } from '@app/types';
import { useEffect, useState } from 'react';
export function useSkin(): Skin {
const [skin, setSkin] = useState(getInitialSkin);
useEffect(() => onSkinChange(setSkin), []);
return skin;
}
@@ -0,0 +1,9 @@
import { getInitialSource, onSourceChange } from '@app/shared/sandbox-listener';
import type { SourceId } from '@app/shared/sources';
import { useEffect, useState } from 'react';
export function useSource(audioOnly?: boolean): SourceId {
const [source, setSource] = useState(() => getInitialSource(audioOnly));
useEffect(() => onSourceChange(setSource), []);
return source;
}
@@ -0,0 +1,8 @@
import { useMemo } from 'react';
import { getStoryboardSrc } from '../sources';
import { useSource } from './use-source';
export function useStoryboard() {
const source = useSource();
return useMemo(() => getStoryboardSrc(source), [source]);
}
@@ -0,0 +1,64 @@
import { SKINS } from '@app/constants';
import type { Skin } from '@app/types';
import { DEFAULT_AUDIO_SOURCE, SOURCES, type SourceId } from './sources';
const params = new URLSearchParams(window.location.search);
function readSkin(): Skin {
const skin = params.get('skin');
return skin && SKINS.includes(skin as Skin) ? (skin as Skin) : 'default';
}
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 currentSkin;
}
export function onSkinChange(callback: (skin: Skin) => void): () => void {
const handler = (event: MessageEvent) => {
if (event.data?.type !== 'skin-change' || !SKINS.includes(event.data.skin)) return;
currentSkin = event.data.skin;
callback(currentSkin);
};
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
};
}
export function getInitialSource(audioOnly?: boolean): SourceId {
const stored = currentSource;
if (audioOnly && SOURCES[stored].type !== 'mp4') {
return DEFAULT_AUDIO_SOURCE;
}
return stored;
}
export function onSourceChange(callback: (source: SourceId) => void): () => void {
const handler = (event: MessageEvent) => {
if (event.data?.type !== 'source-change' || !(event.data.source in SOURCES)) return;
currentSource = event.data.source;
callback(currentSource);
};
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
};
}
+79
View File
@@ -0,0 +1,79 @@
import { getMuxAssetId } from './mux';
export const SOURCES = {
'hls-1': {
label: 'HLS - Big Buck Bunny',
url: 'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8',
playbackId: 'VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA',
type: 'hls',
subType: 'ts',
},
'hls-2': {
label: 'HLS - Elephants Dream',
url: 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8',
playbackId: 'Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008',
type: 'hls',
subType: 'ts',
},
'hls-3': {
label: 'HLS - Dancing Dude',
url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4.m3u8',
playbackId: 'lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4',
type: 'hls',
subType: 'mp4',
},
'hls-4': {
label: 'HLS - View From A Blue Moon Trailer',
url: 'https://stream.mux.com/lyrKpPcGfqyzeI00jZAfW6MvP6GNPrkML.m3u8',
playbackId: 'lyrKpPcGfqyzeI00jZAfW6MvP6GNPrkML',
type: 'hls',
subType: 'mp4',
},
'hls-5': {
label: 'HLS - Mad Max Fury Road Trailer',
url: 'https://stream.mux.com/JX01bG8eB4uaoV3OpDuK602rBfvdSgrMObjwuUOBn4JrQ.m3u8',
playbackId: 'JX01bG8eB4uaoV3OpDuK602rBfvdSgrMObjwuUOBn4JrQ',
type: 'hls',
subType: 'mp4',
},
'mp4-1': {
label: 'MP4 - Dancing Dude',
url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4',
playbackId: 'lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4',
type: 'mp4',
},
'dash-1': {
label: 'DASH - Big Buck Bunny',
url: 'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd',
playbackId: null,
type: 'dash',
},
'dash-2': {
label: 'DASH - Envivio Test Stream',
url: 'https://dash.akamaized.net/envivio/EnvivioDash3/manifest.mpd',
playbackId: null,
type: 'dash',
},
} as const;
export type SourceId = keyof typeof SOURCES;
export const SOURCE_IDS = Object.keys(SOURCES) as SourceId[];
export const MP4_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'mp4');
export const DASH_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'dash');
export const MUX_SOURCE_IDS = SOURCE_IDS.filter((id) => !!SOURCES[id].playbackId);
export const DEFAULT_SOURCE: SourceId = 'hls-1';
export const DEFAULT_AUDIO_SOURCE: SourceId = 'mp4-1';
export const DEFAULT_DASH_SOURCE: SourceId = 'dash-1';
export const BACKGROUND_VIDEO_SRC = 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008/low.mp4';
export function getPosterSrc(source: SourceId): string | undefined {
const id = getMuxAssetId(source);
return id ? `https://image.mux.com/${id}/thumbnail.jpg` : undefined;
}
export function getStoryboardSrc(source: SourceId): string | undefined {
const id = getMuxAssetId(source);
return id ? `https://image.mux.com/${id}/storyboard.vtt` : undefined;
}