mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
chore: move sandbox to apps/ (#1171)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user