Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/sandbox/app/shared/html/skin-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@ 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' },
};

/** Custom element tag names for the live HLS video preset (`@videojs/html/live-video` skins). */
export const LIVE_VIDEO_CSS_SKIN_TAGS: Record<Skin, string> = {
default: 'live-video-skin',
minimal: 'live-video-minimal-skin',
};

export const LIVE_VIDEO_TAILWIND_SKIN_TAGS: Record<Skin, string> = {
default: 'live-video-skin-tailwind',
minimal: 'live-video-minimal-skin-tailwind',
};
52 changes: 50 additions & 2 deletions apps/sandbox/app/shared/html/skins.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Skin, Styling } from '@app/types';
import { CSS_SKIN_TAGS, TAILWIND_SKIN_TAGS } from './skin-tags';
import {
CSS_SKIN_TAGS,
LIVE_VIDEO_CSS_SKIN_TAGS,
LIVE_VIDEO_TAILWIND_SKIN_TAGS,
TAILWIND_SKIN_TAGS,
} from './skin-tags';
import { loadAudioStylesheets, loadVideoStylesheets } from './stylesheets';

async function loadVideoCssSkin(skin: Skin): Promise<string> {
Expand Down Expand Up @@ -58,7 +63,50 @@ async function loadAudioTailwindSkin(skin: Skin): Promise<string> {
return TAILWIND_SKIN_TAGS[skin].audio;
}

export function loadVideoSkinTag(skin: Skin, styling: Styling): Promise<string> {
async function loadLiveVideoCssSkin(skin: Skin): Promise<string> {
if (skin === 'default') {
await import('@videojs/html/live-video/skin');
} else {
await import('@videojs/html/live-video/minimal-skin');
}

loadVideoStylesheets(skin);

return LIVE_VIDEO_CSS_SKIN_TAGS[skin];
}

async function loadLiveVideoTailwindSkin(skin: Skin): Promise<string> {
if (skin === 'default') {
const { LiveVideoSkinTailwindElement } = await import('@videojs/html/live-video/skin.tailwind');
const { getTailwindStyles } = await import('./tailwind-setup');

LiveVideoSkinTailwindElement.styles = getTailwindStyles();
} else {
const { MinimalLiveVideoSkinTailwindElement } = await import('@videojs/html/live-video/minimal-skin.tailwind');
const { getTailwindStyles } = await import('./tailwind-setup');

MinimalLiveVideoSkinTailwindElement.styles = getTailwindStyles();
}

return LIVE_VIDEO_TAILWIND_SKIN_TAGS[skin];
}

type VideoSkinOptions = { live?: boolean };

/**
* Loads and registers the video skin for the given skin / styling combination
* and returns its custom element tag name. Pass `live: true` to swap in the
* `live-video` skin variant (same feature set, trimmed time UI).
*/
export function loadVideoSkinTag(
skin: Skin,
styling: Styling,
{ live = false }: VideoSkinOptions = {}
): Promise<string> {
if (live) {
return styling === 'tailwind' ? loadLiveVideoTailwindSkin(skin) : loadLiveVideoCssSkin(skin);
}

return styling === 'tailwind' ? loadVideoTailwindSkin(skin) : loadVideoCssSkin(skin);
}

Expand Down
2 changes: 2 additions & 0 deletions apps/sandbox/app/shared/react/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { audioFeatures } from '@videojs/react/audio';
import { backgroundFeatures } from '@videojs/react/background';
import { videoFeatures } from '@videojs/react/video';

// The `live-video` preset currently shares `videoFeatures`, so the same provider
// works for both live and VOD playback — only the skin swaps on the source.
export const { Provider: VideoProvider } = createPlayer({
features: videoFeatures,
});
Expand Down
31 changes: 27 additions & 4 deletions apps/sandbox/app/shared/react/skins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ async function loadVideoSkinComponent(skin: Skin, styling: Styling): Promise<Com
return module.MinimalVideoSkin;
}

async function loadLiveVideoSkinComponent(skin: Skin, styling: Styling): Promise<ComponentType<VideoSkinProps>> {
const module = await import('@videojs/react/live-video');

if (styling === 'tailwind') {
return skin === 'default' ? module.LiveVideoSkinTailwind : module.MinimalLiveVideoSkinTailwind;
}

if (skin === 'default') {
await import('@videojs/react/live-video/skin.css');
return module.LiveVideoSkin;
}

await import('@videojs/react/live-video/minimal-skin.css');
return module.MinimalLiveVideoSkin;
}

async function loadAudioSkinComponent(skin: Skin, styling: Styling): Promise<ComponentType<AudioSkinProps>> {
const module = await import('@videojs/react/audio');

Expand Down Expand Up @@ -66,10 +82,17 @@ function useLoadedComponent<Props>(
return component;
}

type VideoSkinComponentProps = { skin: Skin; styling: Styling } & VideoSkinProps;

export function VideoSkinComponent({ skin, styling, ...props }: VideoSkinComponentProps) {
const Component = useLoadedComponent(() => loadVideoSkinComponent(skin, styling), [skin, styling]);
type VideoSkinComponentProps = { skin: Skin; styling: Styling; live?: boolean } & VideoSkinProps;

/**
* Loads the video skin for the given skin/styling. When `live` is true,
* the `live-video` skin variant is used instead.
*/
export function VideoSkinComponent({ skin, styling, live = false, ...props }: VideoSkinComponentProps) {
const Component = useLoadedComponent(
() => (live ? loadLiveVideoSkinComponent(skin, styling) : loadVideoSkinComponent(skin, styling)),
[skin, styling, live]
);

if (!Component) return null;

Expand Down
8 changes: 8 additions & 0 deletions apps/sandbox/app/shared/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const SOURCES = {
url: 'https://stream.mux.com/v69RSHhFelSm4701snP22dYz2jICy4E4FUyk02rW4gxRM.m3u8',
type: 'hls',
subType: 'mp4',
live: true,
},
'mp4-1': {
label: 'MP4 - Dancing Dude',
Expand Down Expand Up @@ -66,12 +67,19 @@ export const DEFAULT_DASH_SOURCE: SourceId = 'dash-1';

export const BACKGROUND_VIDEO_SRC = 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008/low.mp4';

/** Returns true when the given source represents a live stream and should use the live-video skin. */
export function isLiveSource(id: SourceId): boolean {
return (SOURCES[id] as { live?: boolean }).live === true;
}

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 {
// Storyboards aren't generated for live streams, so skip the request entirely.
if (isLiveSource(source)) return undefined;
const id = getMuxAssetId(source);
return id ? `https://image.mux.com/${id}/storyboard.vtt` : undefined;
}
33 changes: 24 additions & 9 deletions apps/sandbox/templates/cdn/main.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import '@app/styles.css';
import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state';
import { CSS_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { CSS_SKIN_TAGS, LIVE_VIDEO_CSS_SKIN_TAGS } from '@app/shared/html/skin-tags';
import { renderStoryboard } from '@app/shared/html/storyboard';
import { loadAudioStylesheets, loadVideoStylesheets } from '@app/shared/html/stylesheets';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { BACKGROUND_VIDEO_SRC, getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
import { BACKGROUND_VIDEO_SRC, getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';
import type { Preset, Skin } from '@app/types';

const html = String.raw;
Expand All @@ -19,16 +19,21 @@ const loadLatest = createLatestLoader();
// CDN module loading — mirrors the exact import graph of each CDN bundle.
// ---------------------------------------------------------------------------

async function loadCdnPreset(preset: Preset, skin: Skin) {
async function loadCdnPreset(preset: Preset, skin: Skin, live: boolean) {
switch (preset) {
case 'video':
case 'hls-video':
case 'mux-video':
case 'native-hls-video':
case 'simple-hls-video':
case 'dash-video':
if (skin === 'minimal') await import('@videojs/html/cdn/video-minimal');
else await import('@videojs/html/cdn/video');
if (live) {
if (skin === 'minimal') await import('@videojs/html/cdn/live-video-minimal');
else await import('@videojs/html/cdn/live-video');
} else {
if (skin === 'minimal') await import('@videojs/html/cdn/video-minimal');
else await import('@videojs/html/cdn/video');
}
break;
case 'audio':
case 'mux-audio':
Expand Down Expand Up @@ -74,9 +79,10 @@ function getPlayerTag(preset: Preset): string {
return 'video-player';
}

function getSkinTag(preset: Preset, skin: Skin): string {
function getSkinTag(preset: Preset, skin: Skin, live: boolean): string {
if (preset === 'background-video') return 'background-video-skin';
if (preset === 'audio' || preset === 'mux-audio') return CSS_SKIN_TAGS[skin].audio;
if (live) return LIVE_VIDEO_CSS_SKIN_TAGS[skin];
return CSS_SKIN_TAGS[skin].video;
}

Expand Down Expand Up @@ -112,23 +118,32 @@ function isVideoPreset(preset: Preset): boolean {
);
}

function canPlayLive(preset: Preset): boolean {
return (
preset === 'hls-video' || preset === 'mux-video' || preset === 'native-hls-video' || preset === 'simple-hls-video'
);
}

async function render() {
const live = canPlayLive(preset) && isLiveSource(state.source);

await loadLatest(async () => {
await loadCdnPreset(preset, state.skin);
await loadCdnPreset(preset, state.skin, live);
await loadCdnMedia(preset);
});

loadStylesheets(preset, state.skin);

const root = document.getElementById('root')!;
const playerTag = getPlayerTag(preset);
const skinTag = getSkinTag(preset, state.skin);
const skinTag = getSkinTag(preset, state.skin, live);
const mediaTag = getMediaTag(preset);
const source = SOURCES[state.source];
const storyboard = isVideoPreset(preset) ? getStoryboardSrc(state.source) : undefined;
const poster = isVideoPreset(preset) ? getPosterSrc(state.source) : undefined;

const sourceAttr = preset === 'background-video' ? `src="${BACKGROUND_VIDEO_SRC}"` : `src="${source.url}"`;
const liveAttrs = live ? 'autoplay muted' : '';

// Background video needs viewport dimensions instead of flex centering.
if (preset === 'background-video') {
Expand Down Expand Up @@ -163,7 +178,7 @@ async function render() {
root.innerHTML = html`
<${playerTag}>
<${skinTag} class="aspect-video max-w-4xl mx-auto">
<${mediaTag} ${sourceAttr} playsinline crossorigin="anonymous">
<${mediaTag} ${sourceAttr} ${liveAttrs} playsinline crossorigin="anonymous">
${renderStoryboard(storyboard)}
</${mediaTag}>
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
Expand Down
8 changes: 5 additions & 3 deletions apps/sandbox/templates/html-hls-video/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@ import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/san
import { loadVideoSkinTag } from '@app/shared/html/skins';
import { renderStoryboard } from '@app/shared/html/storyboard';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';

const html = String.raw;

const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();

async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
const live = isLiveSource(state.source);
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling, { live }));
if (!tag) return;

const storyboard = getStoryboardSrc(state.source);
const poster = getPosterSrc(state.source);
const liveAttrs = live ? 'autoplay muted' : '';

document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="aspect-video max-w-4xl mx-auto">
<hls-video src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
<hls-video src="${SOURCES[state.source].url}" ${liveAttrs} playsinline crossorigin="anonymous">
${renderStoryboard(storyboard)}
</hls-video>
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
Expand Down
8 changes: 5 additions & 3 deletions apps/sandbox/templates/html-mux-video/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@ import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/san
import { loadVideoSkinTag } from '@app/shared/html/skins';
import { renderStoryboard } from '@app/shared/html/storyboard';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';

const html = String.raw;

const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();

async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
const live = isLiveSource(state.source);
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling, { live }));
if (!tag) return;

const storyboard = getStoryboardSrc(state.source);
const poster = getPosterSrc(state.source);
const liveAttrs = live ? 'autoplay muted' : '';

document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="aspect-video max-w-4xl mx-auto">
<mux-video src="${SOURCES[state.source].url}" debug playsinline crossorigin="anonymous">
<mux-video src="${SOURCES[state.source].url}" debug ${liveAttrs} playsinline crossorigin="anonymous">
${renderStoryboard(storyboard)}
</mux-video>
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
Expand Down
8 changes: 5 additions & 3 deletions apps/sandbox/templates/html-native-hls-video/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@ import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/san
import { loadVideoSkinTag } from '@app/shared/html/skins';
import { renderStoryboard } from '@app/shared/html/storyboard';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';

const html = String.raw;

const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();

async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
const live = isLiveSource(state.source);
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling, { live }));
if (!tag) return;

const storyboard = getStoryboardSrc(state.source);
const poster = getPosterSrc(state.source);
const liveAttrs = live ? 'autoplay muted' : '';

document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="w-full aspect-video max-w-4xl mx-auto">
<native-hls-video src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
<native-hls-video src="${SOURCES[state.source].url}" ${liveAttrs} playsinline crossorigin="anonymous">
${renderStoryboard(storyboard)}
</native-hls-video>
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
Expand Down
8 changes: 5 additions & 3 deletions apps/sandbox/templates/html-simple-hls-video/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@ import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/san
import { loadVideoSkinTag } from '@app/shared/html/skins';
import { renderStoryboard } from '@app/shared/html/storyboard';
import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener';
import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources';
import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources';

const html = String.raw;

const state = createHtmlSandboxState();
const loadLatest = createLatestLoader();

async function render() {
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling));
const live = isLiveSource(state.source);
const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling, { live }));
if (!tag) return;

const storyboard = getStoryboardSrc(state.source);
const poster = getPosterSrc(state.source);
const liveAttrs = live ? 'autoplay muted' : '';

document.getElementById('root')!.innerHTML = html`
<video-player>
<${tag} class="aspect-video max-w-4xl mx-auto">
<simple-hls-video src="${SOURCES[state.source].url}" playsinline crossorigin="anonymous">
<simple-hls-video src="${SOURCES[state.source].url}" ${liveAttrs} playsinline crossorigin="anonymous">
${renderStoryboard(storyboard)}
</simple-hls-video>
${poster ? html`<img slot="poster" src="${poster}" alt="Video poster" />` : ''}
Expand Down
Loading
Loading