From d9c00491e7984aeaa01eec2d4450ac148129c205 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Wed, 22 Apr 2026 12:43:27 -0700 Subject: [PATCH] feat(packages): add live-video and live-audio presets (#1399) --- apps/sandbox/app/shared/html/skin-tags.ts | 11 + apps/sandbox/app/shared/html/skins.ts | 52 +++- apps/sandbox/app/shared/react/providers.ts | 2 + apps/sandbox/app/shared/react/skins.tsx | 29 ++- apps/sandbox/app/shared/sources.ts | 8 + apps/sandbox/templates/cdn/main.ts | 33 ++- apps/sandbox/templates/html-hls-video/main.ts | 8 +- apps/sandbox/templates/html-mux-video/main.ts | 8 +- .../templates/html-native-hls-video/main.ts | 8 +- .../templates/html-simple-hls-video/main.ts | 8 +- .../templates/react-hls-video/main.tsx | 13 +- .../templates/react-mux-video/main.tsx | 13 +- .../templates/react-native-hls-video/main.tsx | 6 +- .../templates/react-simple-hls-video/main.tsx | 13 +- packages/core/src/dom/media/types.ts | 35 ++- .../core/src/dom/store/features/presets.ts | 42 ++- packages/html/package.json | 22 ++ packages/html/src/cdn/live-video-minimal.ts | 1 + packages/html/src/cdn/live-video.ts | 1 + .../src/define/live-audio/minimal-skin.css | 3 + .../live-audio/minimal-skin.tailwind.ts | 86 +++++++ .../src/define/live-audio/minimal-skin.ts | 76 ++++++ packages/html/src/define/live-audio/skin.css | 3 + .../src/define/live-audio/skin.tailwind.ts | 86 +++++++ packages/html/src/define/live-audio/skin.ts | 83 ++++++ .../src/define/live-video/minimal-skin.css | 3 + .../live-video/minimal-skin.tailwind.ts | 120 +++++++++ .../src/define/live-video/minimal-skin.ts | 112 ++++++++ packages/html/src/define/live-video/skin.css | 3 + .../src/define/live-video/skin.tailwind.ts | 122 +++++++++ packages/html/src/define/live-video/skin.ts | 128 +++++++++ packages/html/src/presets/live-audio.ts | 6 + packages/html/src/presets/live-video.ts | 6 + packages/html/tsdown.cdn.config.ts | 2 + packages/react/package.json | 22 ++ .../react/src/presets/live-audio/index.ts | 7 + .../src/presets/live-audio/minimal-skin.css | 1 + .../live-audio/minimal-skin.tailwind.tsx | 149 +++++++++++ .../src/presets/live-audio/minimal-skin.tsx | 115 +++++++++ .../react/src/presets/live-audio/skin.css | 1 + .../src/presets/live-audio/skin.tailwind.tsx | 142 ++++++++++ .../react/src/presets/live-audio/skin.tsx | 115 +++++++++ .../react/src/presets/live-video/index.ts | 7 + .../src/presets/live-video/minimal-skin.css | 1 + .../live-video/minimal-skin.tailwind.tsx | 240 +++++++++++++++++ .../src/presets/live-video/minimal-skin.tsx | 195 ++++++++++++++ .../react/src/presets/live-video/skin.css | 1 + .../src/presets/live-video/skin.tailwind.tsx | 242 ++++++++++++++++++ .../react/src/presets/live-video/skin.tsx | 213 +++++++++++++++ 49 files changed, 2552 insertions(+), 51 deletions(-) create mode 100644 packages/html/src/cdn/live-video-minimal.ts create mode 100644 packages/html/src/cdn/live-video.ts create mode 100644 packages/html/src/define/live-audio/minimal-skin.css create mode 100644 packages/html/src/define/live-audio/minimal-skin.tailwind.ts create mode 100644 packages/html/src/define/live-audio/minimal-skin.ts create mode 100644 packages/html/src/define/live-audio/skin.css create mode 100644 packages/html/src/define/live-audio/skin.tailwind.ts create mode 100644 packages/html/src/define/live-audio/skin.ts create mode 100644 packages/html/src/define/live-video/minimal-skin.css create mode 100644 packages/html/src/define/live-video/minimal-skin.tailwind.ts create mode 100644 packages/html/src/define/live-video/minimal-skin.ts create mode 100644 packages/html/src/define/live-video/skin.css create mode 100644 packages/html/src/define/live-video/skin.tailwind.ts create mode 100644 packages/html/src/define/live-video/skin.ts create mode 100644 packages/html/src/presets/live-audio.ts create mode 100644 packages/html/src/presets/live-video.ts create mode 100644 packages/react/src/presets/live-audio/index.ts create mode 100644 packages/react/src/presets/live-audio/minimal-skin.css create mode 100644 packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx create mode 100644 packages/react/src/presets/live-audio/minimal-skin.tsx create mode 100644 packages/react/src/presets/live-audio/skin.css create mode 100644 packages/react/src/presets/live-audio/skin.tailwind.tsx create mode 100644 packages/react/src/presets/live-audio/skin.tsx create mode 100644 packages/react/src/presets/live-video/index.ts create mode 100644 packages/react/src/presets/live-video/minimal-skin.css create mode 100644 packages/react/src/presets/live-video/minimal-skin.tailwind.tsx create mode 100644 packages/react/src/presets/live-video/minimal-skin.tsx create mode 100644 packages/react/src/presets/live-video/skin.css create mode 100644 packages/react/src/presets/live-video/skin.tailwind.tsx create mode 100644 packages/react/src/presets/live-video/skin.tsx diff --git a/apps/sandbox/app/shared/html/skin-tags.ts b/apps/sandbox/app/shared/html/skin-tags.ts index b51be993..730f408e 100644 --- a/apps/sandbox/app/shared/html/skin-tags.ts +++ b/apps/sandbox/app/shared/html/skin-tags.ts @@ -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 = { + default: 'live-video-skin', + minimal: 'live-video-minimal-skin', +}; + +export const LIVE_VIDEO_TAILWIND_SKIN_TAGS: Record = { + default: 'live-video-skin-tailwind', + minimal: 'live-video-minimal-skin-tailwind', +}; diff --git a/apps/sandbox/app/shared/html/skins.ts b/apps/sandbox/app/shared/html/skins.ts index 8b68e14c..d6d63a57 100644 --- a/apps/sandbox/app/shared/html/skins.ts +++ b/apps/sandbox/app/shared/html/skins.ts @@ -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 { @@ -58,7 +63,50 @@ async function loadAudioTailwindSkin(skin: Skin): Promise { return TAILWIND_SKIN_TAGS[skin].audio; } -export function loadVideoSkinTag(skin: Skin, styling: Styling): Promise { +async function loadLiveVideoCssSkin(skin: Skin): Promise { + 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 { + 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 { + if (live) { + return styling === 'tailwind' ? loadLiveVideoTailwindSkin(skin) : loadLiveVideoCssSkin(skin); + } + return styling === 'tailwind' ? loadVideoTailwindSkin(skin) : loadVideoCssSkin(skin); } diff --git a/apps/sandbox/app/shared/react/providers.ts b/apps/sandbox/app/shared/react/providers.ts index 8e6980f9..65be1ebe 100644 --- a/apps/sandbox/app/shared/react/providers.ts +++ b/apps/sandbox/app/shared/react/providers.ts @@ -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, }); diff --git a/apps/sandbox/app/shared/react/skins.tsx b/apps/sandbox/app/shared/react/skins.tsx index cf8c2cf3..998670f4 100644 --- a/apps/sandbox/app/shared/react/skins.tsx +++ b/apps/sandbox/app/shared/react/skins.tsx @@ -20,6 +20,22 @@ async function loadVideoSkinComponent(skin: Skin, styling: Styling): Promise> { + 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> { const module = await import('@videojs/react/audio'); @@ -66,10 +82,17 @@ function useLoadedComponent( return component; } -type VideoSkinComponentProps = { skin: Skin; styling: Styling } & VideoSkinProps; +type VideoSkinComponentProps = { skin: Skin; styling: Styling; live?: boolean } & VideoSkinProps; -export function VideoSkinComponent({ skin, styling, ...props }: VideoSkinComponentProps) { - const Component = useLoadedComponent(() => loadVideoSkinComponent(skin, styling), [skin, styling]); +/** + * 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; diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index 752050d1..12dc1294 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -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', @@ -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; } diff --git a/apps/sandbox/templates/cdn/main.ts b/apps/sandbox/templates/cdn/main.ts index 464543ee..03a535a8 100644 --- a/apps/sandbox/templates/cdn/main.ts +++ b/apps/sandbox/templates/cdn/main.ts @@ -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; @@ -19,7 +19,7 @@ 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': @@ -27,8 +27,13 @@ async function loadCdnPreset(preset: Preset, skin: Skin) { 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': @@ -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; } @@ -112,9 +118,17 @@ 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); }); @@ -122,13 +136,14 @@ async function render() { 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') { @@ -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)} ${poster ? html`Video poster` : ''} diff --git a/apps/sandbox/templates/html-hls-video/main.ts b/apps/sandbox/templates/html-hls-video/main.ts index b9f9b974..2bbdb93c 100644 --- a/apps/sandbox/templates/html-hls-video/main.ts +++ b/apps/sandbox/templates/html-hls-video/main.ts @@ -5,7 +5,7 @@ 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; @@ -13,16 +13,18 @@ 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` <${tag} class="aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts index d1ead60a..b90b0cbc 100644 --- a/apps/sandbox/templates/html-mux-video/main.ts +++ b/apps/sandbox/templates/html-mux-video/main.ts @@ -5,7 +5,7 @@ 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; @@ -13,16 +13,18 @@ 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` <${tag} class="aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} diff --git a/apps/sandbox/templates/html-native-hls-video/main.ts b/apps/sandbox/templates/html-native-hls-video/main.ts index ae48fd39..e62da463 100644 --- a/apps/sandbox/templates/html-native-hls-video/main.ts +++ b/apps/sandbox/templates/html-native-hls-video/main.ts @@ -5,7 +5,7 @@ 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; @@ -13,16 +13,18 @@ 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` <${tag} class="w-full aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} diff --git a/apps/sandbox/templates/html-simple-hls-video/main.ts b/apps/sandbox/templates/html-simple-hls-video/main.ts index 823eac92..21c820aa 100644 --- a/apps/sandbox/templates/html-simple-hls-video/main.ts +++ b/apps/sandbox/templates/html-simple-hls-video/main.ts @@ -5,7 +5,7 @@ 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; @@ -13,16 +13,18 @@ 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` <${tag} class="aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} diff --git a/apps/sandbox/templates/react-hls-video/main.tsx b/apps/sandbox/templates/react-hls-video/main.tsx index 149cdbf0..97fbbf9d 100644 --- a/apps/sandbox/templates/react-hls-video/main.tsx +++ b/apps/sandbox/templates/react-hls-video/main.tsx @@ -6,7 +6,7 @@ import { usePoster } from '@app/shared/react/use-poster'; import { useSkin } from '@app/shared/react/use-skin'; import { useSource } from '@app/shared/react/use-source'; import { useStoryboard } from '@app/shared/react/use-storyboard'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; import type { Styling } from '@app/types'; import { HlsVideo } from '@videojs/react/media/hls-video'; import { useMemo } from 'react'; @@ -22,11 +22,18 @@ function App() { const styling = useMemo(readStyling, []); const poster = usePoster(); const storyboard = useStoryboard(); + const live = isLiveSource(source); return ( - - + + diff --git a/apps/sandbox/templates/react-mux-video/main.tsx b/apps/sandbox/templates/react-mux-video/main.tsx index 945123f8..113e916c 100644 --- a/apps/sandbox/templates/react-mux-video/main.tsx +++ b/apps/sandbox/templates/react-mux-video/main.tsx @@ -6,7 +6,7 @@ import { usePoster } from '@app/shared/react/use-poster'; import { useSkin } from '@app/shared/react/use-skin'; import { useSource } from '@app/shared/react/use-source'; import { useStoryboard } from '@app/shared/react/use-storyboard'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; import type { Styling } from '@app/types'; import { MuxVideo } from '@videojs/react/media/mux-video'; import { useMemo } from 'react'; @@ -22,11 +22,18 @@ function App() { const styling = useMemo(readStyling, []); const poster = usePoster(); const storyboard = useStoryboard(); + const live = isLiveSource(source); return ( - - + + diff --git a/apps/sandbox/templates/react-native-hls-video/main.tsx b/apps/sandbox/templates/react-native-hls-video/main.tsx index f1202a41..ad636fae 100644 --- a/apps/sandbox/templates/react-native-hls-video/main.tsx +++ b/apps/sandbox/templates/react-native-hls-video/main.tsx @@ -6,7 +6,7 @@ import { usePoster } from '@app/shared/react/use-poster'; import { useSkin } from '@app/shared/react/use-skin'; import { useSource } from '@app/shared/react/use-source'; import { useStoryboard } from '@app/shared/react/use-storyboard'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; import type { Styling } from '@app/types'; import { NativeHlsVideo } from '@videojs/react/media/native-hls-video'; import { useMemo } from 'react'; @@ -22,6 +22,7 @@ function App() { const styling = useMemo(readStyling, []); const poster = usePoster(); const storyboard = useStoryboard(); + const live = isLiveSource(source); return ( @@ -29,9 +30,10 @@ function App() { poster={poster} skin={skin} styling={styling} + live={live} className="w-full aspect-video max-w-4xl mx-auto" > - + diff --git a/apps/sandbox/templates/react-simple-hls-video/main.tsx b/apps/sandbox/templates/react-simple-hls-video/main.tsx index 6fbfeffe..22bb332e 100644 --- a/apps/sandbox/templates/react-simple-hls-video/main.tsx +++ b/apps/sandbox/templates/react-simple-hls-video/main.tsx @@ -6,7 +6,7 @@ import { usePoster } from '@app/shared/react/use-poster'; import { useSkin } from '@app/shared/react/use-skin'; import { useSource } from '@app/shared/react/use-source'; import { useStoryboard } from '@app/shared/react/use-storyboard'; -import { SOURCES } from '@app/shared/sources'; +import { isLiveSource, SOURCES } from '@app/shared/sources'; import type { Styling } from '@app/types'; import { SimpleHlsVideo } from '@videojs/react/media/simple-hls-video'; import { useMemo } from 'react'; @@ -22,11 +22,18 @@ function App() { const styling = useMemo(readStyling, []); const poster = usePoster(); const storyboard = useStoryboard(); + const live = isLiveSource(source); return ( - - + + diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index a8caebb7..6838fc2e 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -9,7 +9,6 @@ import type { MediaPlaybackState, MediaRemotePlaybackState, MediaSourceState, - MediaStreamTypeState, MediaTextTrackState, MediaTimeState, MediaVolumeState, @@ -45,7 +44,6 @@ export type VideoFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, - PlayerFeature, PlayerFeature, PlayerFeature, PlayerFeature, @@ -61,7 +59,6 @@ export type AudioFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, - PlayerFeature, PlayerFeature, PlayerFeature, ]; @@ -70,17 +67,35 @@ export type AudioFeatures = [ export type BackgroundFeatures = []; /** - * Features for a live video player. Structurally identical to - * {@link VideoFeatures} — the "live" presets share the same store but ship a - * skin that omits duration-oriented UI. + * Features for a live video player. Mirrors {@link VideoFeatures} without the + * playback-rate feature, which isn't meaningful for live streams. */ -export type LiveVideoFeatures = VideoFeatures; +export type LiveVideoFeatures = [ + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, +]; /** - * Features for a live audio player. Structurally identical to - * {@link AudioFeatures}. + * Features for a live audio player. Mirrors {@link AudioFeatures} without the + * playback-rate feature, which isn't meaningful for live streams. */ -export type LiveAudioFeatures = AudioFeatures; +export type LiveAudioFeatures = [ + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, + PlayerFeature, +]; export type VideoPlayerStore = PlayerStore; diff --git a/packages/core/src/dom/store/features/presets.ts b/packages/core/src/dom/store/features/presets.ts index fb4b4161..5b04fd5f 100644 --- a/packages/core/src/dom/store/features/presets.ts +++ b/packages/core/src/dom/store/features/presets.ts @@ -1,4 +1,10 @@ -import type { AudioFeatures, BackgroundFeatures, VideoFeatures } from '../../media/types'; +import type { + AudioFeatures, + BackgroundFeatures, + LiveAudioFeatures, + LiveVideoFeatures, + VideoFeatures, +} from '../../media/types'; import { bufferFeature } from './buffer'; import { controlsFeature } from './controls'; import { errorFeature } from './error'; @@ -8,7 +14,6 @@ import { playbackFeature } from './playback'; import { playbackRateFeature } from './playback-rate'; import { remotePlaybackFeature } from './remote-playback'; import { sourceFeature } from './source'; -import { streamTypeFeature } from './stream-type'; import { textTrackFeature } from './text-track'; import { timeFeature } from './time'; import { volumeFeature } from './volume'; @@ -19,7 +24,6 @@ export const videoFeatures: VideoFeatures = [ volumeFeature, timeFeature, sourceFeature, - streamTypeFeature, bufferFeature, fullscreenFeature, pipFeature, @@ -35,10 +39,40 @@ export const audioFeatures: AudioFeatures = [ volumeFeature, timeFeature, sourceFeature, - streamTypeFeature, bufferFeature, errorFeature, ]; // TODO: Add background video features (e.g., playback, source, buffer) export const backgroundFeatures: BackgroundFeatures = []; + +/** + * Features for a live video player. Mirrors {@link videoFeatures} without the + * playback-rate feature, which isn't meaningful for live streams. + */ +export const liveVideoFeatures: LiveVideoFeatures = [ + playbackFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + fullscreenFeature, + pipFeature, + remotePlaybackFeature, + controlsFeature, + textTrackFeature, + errorFeature, +]; + +/** + * Features for a live audio player. Mirrors {@link audioFeatures} without the + * playback-rate feature, which isn't meaningful for live streams. + */ +export const liveAudioFeatures: LiveAudioFeatures = [ + playbackFeature, + volumeFeature, + timeFeature, + sourceFeature, + bufferFeature, + errorFeature, +]; diff --git a/packages/html/package.json b/packages/html/package.json index 19373e3f..87cb84e0 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -59,6 +59,28 @@ "development": "./dist/dev/define/background/*.js", "default": "./dist/default/define/background/*.js" }, + "./live-video": { + "types": "./dist/dev/presets/live-video.d.ts", + "development": "./dist/dev/presets/live-video.js", + "default": "./dist/default/presets/live-video.js" + }, + "./live-video/*.css": "./dist/default/define/live-video/*.css", + "./live-video/*": { + "types": "./dist/dev/define/live-video/*.d.ts", + "development": "./dist/dev/define/live-video/*.js", + "default": "./dist/default/define/live-video/*.js" + }, + "./live-audio": { + "types": "./dist/dev/presets/live-audio.d.ts", + "development": "./dist/dev/presets/live-audio.js", + "default": "./dist/default/presets/live-audio.js" + }, + "./live-audio/*.css": "./dist/default/define/live-audio/*.css", + "./live-audio/*": { + "types": "./dist/dev/define/live-audio/*.d.ts", + "development": "./dist/dev/define/live-audio/*.js", + "default": "./dist/default/define/live-audio/*.js" + }, "./ui/*": { "types": "./dist/dev/define/ui/*.d.ts", "development": "./dist/dev/define/ui/*.js", diff --git a/packages/html/src/cdn/live-video-minimal.ts b/packages/html/src/cdn/live-video-minimal.ts new file mode 100644 index 00000000..476f147c --- /dev/null +++ b/packages/html/src/cdn/live-video-minimal.ts @@ -0,0 +1 @@ +import '../define/live-video/minimal-skin'; diff --git a/packages/html/src/cdn/live-video.ts b/packages/html/src/cdn/live-video.ts new file mode 100644 index 00000000..a11c2bb3 --- /dev/null +++ b/packages/html/src/cdn/live-video.ts @@ -0,0 +1 @@ +import '../define/live-video/skin'; diff --git a/packages/html/src/define/live-audio/minimal-skin.css b/packages/html/src/define/live-audio/minimal-skin.css new file mode 100644 index 00000000..c4239c54 --- /dev/null +++ b/packages/html/src/define/live-audio/minimal-skin.css @@ -0,0 +1,3 @@ +@import "../base.css"; +@import "../shared.css"; +@import "@videojs/skins/minimal/css/audio.css"; diff --git a/packages/html/src/define/live-audio/minimal-skin.tailwind.ts b/packages/html/src/define/live-audio/minimal-skin.tailwind.ts new file mode 100644 index 00000000..f52b59e9 --- /dev/null +++ b/packages/html/src/define/live-audio/minimal-skin.tailwind.ts @@ -0,0 +1,86 @@ +import { renderIcon } from '@videojs/icons/render/minimal'; +import { + button, + buttonGroup, + controls, + error, + icon, + iconState, + popup, + root, + slider, +} from '@videojs/skins/minimal/tailwind/audio.tailwind'; +import { createTemplate } from '@videojs/utils/dom'; +import { cn } from '@videojs/utils/style'; +import { safeDefine } from '../safe-define'; +import { SkinElement } from '../skin-element'; + +// Reuse the audio preset's minimal UI element registrations. +import '../audio/minimal-ui'; + +function getTemplateHTML() { + return /*html*/ ` + + + + + + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+ +
+ +
+ + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} + ${renderIcon('play', { class: cn(icon, iconState.play.play) })} + ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + +
+ + + +
+ + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} + ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} + ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} + + + + + + + + + + +
+
+
+
+ `; +} + +export class MinimalLiveAudioSkinTailwindElement extends SkinElement { + static readonly tagName = 'live-audio-minimal-skin-tailwind'; + static template = createTemplate(getTemplateHTML()); +} + +safeDefine(MinimalLiveAudioSkinTailwindElement); + +declare global { + interface HTMLElementTagNameMap { + [MinimalLiveAudioSkinTailwindElement.tagName]: MinimalLiveAudioSkinTailwindElement; + } +} diff --git a/packages/html/src/define/live-audio/minimal-skin.ts b/packages/html/src/define/live-audio/minimal-skin.ts new file mode 100644 index 00000000..63f06788 --- /dev/null +++ b/packages/html/src/define/live-audio/minimal-skin.ts @@ -0,0 +1,76 @@ +import { renderIcon } from '@videojs/icons/render/minimal'; +import { createShadowStyle, createTemplate } from '@videojs/utils/dom'; +import { safeDefine } from '../safe-define'; +import { SkinElement } from '../skin-element'; +import styles from './minimal-skin.css?inline'; + +// Reuse the audio preset's minimal UI element registrations. +import '../audio/minimal-ui'; + +function getTemplateHTML() { + return /*html*/ ` + + + + + + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+ +
+ +
+ + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + +
+ + + +
+ + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} + ${renderIcon('volume-low', { class: 'media-icon media-icon--volume-low' })} + ${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })} + + + + + + + + + + +
+
+
+
+ `; +} + +export class MinimalLiveAudioSkinElement extends SkinElement { + static readonly tagName = 'live-audio-minimal-skin'; + static styles = createShadowStyle(styles); + static template = createTemplate(getTemplateHTML()); +} + +safeDefine(MinimalLiveAudioSkinElement); + +declare global { + interface HTMLElementTagNameMap { + [MinimalLiveAudioSkinElement.tagName]: MinimalLiveAudioSkinElement; + } +} diff --git a/packages/html/src/define/live-audio/skin.css b/packages/html/src/define/live-audio/skin.css new file mode 100644 index 00000000..83ebd47b --- /dev/null +++ b/packages/html/src/define/live-audio/skin.css @@ -0,0 +1,3 @@ +@import "../base.css"; +@import "../shared.css"; +@import "@videojs/skins/default/css/audio.css"; diff --git a/packages/html/src/define/live-audio/skin.tailwind.ts b/packages/html/src/define/live-audio/skin.tailwind.ts new file mode 100644 index 00000000..a8456073 --- /dev/null +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -0,0 +1,86 @@ +import { renderIcon } from '@videojs/icons/render'; +import { + button, + buttonGroup, + controls, + error, + icon, + iconState, + popup, + root, + slider, +} from '@videojs/skins/default/tailwind/audio.tailwind'; +import { createTemplate } from '@videojs/utils/dom'; +import { cn } from '@videojs/utils/style'; +import { safeDefine } from '../safe-define'; +import { SkinElement } from '../skin-element'; + +// Reuse the audio preset's UI element registrations. +import '../audio/ui'; + +function getTemplateHTML() { + return /*html*/ ` + + + + + + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+ +
+ +
+ + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} + ${renderIcon('play', { class: cn(icon, iconState.play.play) })} + ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + +
+ + + +
+ + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} + ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} + ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} + + + + + + + + + + +
+
+
+
+ `; +} + +export class LiveAudioSkinTailwindElement extends SkinElement { + static readonly tagName = 'live-audio-skin-tailwind'; + static template = createTemplate(getTemplateHTML()); +} + +safeDefine(LiveAudioSkinTailwindElement); + +declare global { + interface HTMLElementTagNameMap { + [LiveAudioSkinTailwindElement.tagName]: LiveAudioSkinTailwindElement; + } +} diff --git a/packages/html/src/define/live-audio/skin.ts b/packages/html/src/define/live-audio/skin.ts new file mode 100644 index 00000000..ee0cee7a --- /dev/null +++ b/packages/html/src/define/live-audio/skin.ts @@ -0,0 +1,83 @@ +import { renderIcon } from '@videojs/icons/render'; +import { createShadowStyle, createTemplate } from '@videojs/utils/dom'; +import { safeDefine } from '../safe-define'; +import { SkinElement } from '../skin-element'; +import styles from './skin.css?inline'; + +// Reuse the audio preset's UI element registrations. +import '../audio/ui'; + +function getTemplateHTML() { + return /*html*/ ` + + + + + + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+ +
+ +
+ + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + +
+ + + +
+ + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} + ${renderIcon('volume-low', { class: 'media-icon media-icon--volume-low' })} + ${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })} + + + + + + + + + + +
+
+
+ + + + + + + +
+ `; +} + +export class LiveAudioSkinElement extends SkinElement { + static readonly tagName = 'live-audio-skin'; + static styles = createShadowStyle(styles); + static template = createTemplate(getTemplateHTML()); +} + +safeDefine(LiveAudioSkinElement); + +declare global { + interface HTMLElementTagNameMap { + [LiveAudioSkinElement.tagName]: LiveAudioSkinElement; + } +} diff --git a/packages/html/src/define/live-video/minimal-skin.css b/packages/html/src/define/live-video/minimal-skin.css new file mode 100644 index 00000000..28f9f8da --- /dev/null +++ b/packages/html/src/define/live-video/minimal-skin.css @@ -0,0 +1,3 @@ +@import "../base.css"; +@import "../shared.css"; +@import "@videojs/skins/minimal/css/video.css"; diff --git a/packages/html/src/define/live-video/minimal-skin.tailwind.ts b/packages/html/src/define/live-video/minimal-skin.tailwind.ts new file mode 100644 index 00000000..3634e38a --- /dev/null +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -0,0 +1,120 @@ +import { renderIcon } from '@videojs/icons/render/minimal'; +import { + bufferingIndicator, + button, + buttonGroupEnd, + buttonGroupStart, + controls, + error, + icon, + iconState, + overlay, + popup, + poster, + root, + slider, +} from '@videojs/skins/minimal/tailwind/video.tailwind'; +import { createTemplate } from '@videojs/utils/dom'; +import { cn } from '@videojs/utils/style'; +import { safeDefine } from '../safe-define'; +import { SkinElement } from '../skin-element'; + +// Reuse the video preset's minimal UI element registrations. +import '../video/minimal-ui'; + +function getTemplateHTML() { + return /*html*/ ` + + + + + + + + + + + ${renderIcon('spinner')} + + + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+ + + +
+ + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} + ${renderIcon('play', { class: cn(icon, iconState.play.play) })} + ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + +
+ + + +
+ + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} + ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} + ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} + + + + + + + + + + + + ${renderIcon('captions-off', { class: cn(icon, iconState.captions.off) })} + ${renderIcon('captions-on', { class: cn(icon, iconState.captions.on) })} + + + + ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} + ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} + + + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} + ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} + + + + ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} + ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} + + +
+
+
+ +
+
+ `; +} + +export class MinimalLiveVideoSkinTailwindElement extends SkinElement { + static readonly tagName = 'live-video-minimal-skin-tailwind'; + static template = createTemplate(getTemplateHTML()); +} + +safeDefine(MinimalLiveVideoSkinTailwindElement); + +declare global { + interface HTMLElementTagNameMap { + [MinimalLiveVideoSkinTailwindElement.tagName]: MinimalLiveVideoSkinTailwindElement; + } +} diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts new file mode 100644 index 00000000..35e9e5f9 --- /dev/null +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -0,0 +1,112 @@ +import { renderIcon } from '@videojs/icons/render/minimal'; +import { createShadowStyle, createTemplate } from '@videojs/utils/dom'; +import { safeDefine } from '../safe-define'; +import { SkinElement } from '../skin-element'; +import styles from './minimal-skin.css?inline'; + +// Reuse the video preset's minimal UI element registrations. +import '../video/minimal-ui'; + +function getTemplateHTML() { + return /*html*/ ` + + + + + + + + + + + ${renderIcon('spinner', { class: 'media-icon' })} + + + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+ + + +
+ + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + +
+ + + +
+ + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} + ${renderIcon('volume-low', { class: 'media-icon media-icon--volume-low' })} + ${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })} + + + + + + + + + + + + + ${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })} + ${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })} + + + Toggle captions + + + + ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} + ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} + + + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} + ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} + + + + + ${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })} + ${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })} + + +
+
+
+ +
+
+ `; +} + +export class MinimalLiveVideoSkinElement extends SkinElement { + static readonly tagName = 'live-video-minimal-skin'; + static styles = createShadowStyle(styles); + static template = createTemplate(getTemplateHTML()); +} + +safeDefine(MinimalLiveVideoSkinElement); + +declare global { + interface HTMLElementTagNameMap { + [MinimalLiveVideoSkinElement.tagName]: MinimalLiveVideoSkinElement; + } +} diff --git a/packages/html/src/define/live-video/skin.css b/packages/html/src/define/live-video/skin.css new file mode 100644 index 00000000..15c9f8e0 --- /dev/null +++ b/packages/html/src/define/live-video/skin.css @@ -0,0 +1,3 @@ +@import "../base.css"; +@import "../shared.css"; +@import "@videojs/skins/default/css/video.css"; diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts new file mode 100644 index 00000000..32aec808 --- /dev/null +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -0,0 +1,122 @@ +import { renderIcon } from '@videojs/icons/render'; +import { + bufferingIndicator, + button, + buttonGroupEnd, + buttonGroupStart, + controls, + error, + icon, + iconState, + overlay, + popup, + poster, + root, + slider, +} from '@videojs/skins/default/tailwind/video.tailwind'; +import { createTemplate } from '@videojs/utils/dom'; +import { cn } from '@videojs/utils/style'; +import { safeDefine } from '../safe-define'; +import { SkinElement } from '../skin-element'; + +// Reuse the video preset's UI element registrations. +import '../video/ui'; + +function getTemplateHTML() { + return /*html*/ ` + + + + + + + + + + +
+ ${renderIcon('spinner')} +
+
+ + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+ + + +
+ + ${renderIcon('restart', { class: cn(icon, iconState.play.restart) })} + ${renderIcon('play', { class: cn(icon, iconState.play.play) })} + ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + +
+ + + +
+ + ${renderIcon('volume-off', { class: cn(icon, iconState.mute.volumeOff) })} + ${renderIcon('volume-low', { class: cn(icon, iconState.mute.volumeLow) })} + ${renderIcon('volume-high', { class: cn(icon, iconState.mute.volumeHigh) })} + + + + + + + + + + + + ${renderIcon('captions-off', { class: cn(icon, iconState.captions.off) })} + ${renderIcon('captions-on', { class: cn(icon, iconState.captions.on) })} + + + + ${renderIcon('cast-enter', { class: cn(icon, iconState.cast.enter) })} + ${renderIcon('cast-exit', { class: cn(icon, iconState.cast.exit) })} + + + + ${renderIcon('pip-enter', { class: cn(icon, iconState.pip.off) })} + ${renderIcon('pip-exit', { class: cn(icon, iconState.pip.on) })} + + + + ${renderIcon('fullscreen-enter', { class: cn(icon, iconState.fullscreen.enter) })} + ${renderIcon('fullscreen-exit', { class: cn(icon, iconState.fullscreen.exit) })} + + +
+
+
+ +
+
+ `; +} + +export class LiveVideoSkinTailwindElement extends SkinElement { + static readonly tagName = 'live-video-skin-tailwind'; + static template = createTemplate(getTemplateHTML()); +} + +safeDefine(LiveVideoSkinTailwindElement); + +declare global { + interface HTMLElementTagNameMap { + [LiveVideoSkinTailwindElement.tagName]: LiveVideoSkinTailwindElement; + } +} diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts new file mode 100644 index 00000000..d051dede --- /dev/null +++ b/packages/html/src/define/live-video/skin.ts @@ -0,0 +1,128 @@ +import { renderIcon } from '@videojs/icons/render'; +import { createShadowStyle, createTemplate } from '@videojs/utils/dom'; +import { safeDefine } from '../safe-define'; +import { SkinElement } from '../skin-element'; +import styles from './skin.css?inline'; + +// Reuse the video preset's UI element registrations (player, container, +// controls, buttons, etc.) — the live variant only differs in its template. +import '../video/ui'; + +function getTemplateHTML() { + return /*html*/ ` + + + + + + + + + + +
+ ${renderIcon('spinner', { class: 'media-icon' })} +
+
+ + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+ + + +
+ + ${renderIcon('restart', { class: 'media-icon media-icon--restart' })} + ${renderIcon('play', { class: 'media-icon media-icon--play' })} + ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + +
+ + + +
+ + ${renderIcon('volume-off', { class: 'media-icon media-icon--volume-off' })} + ${renderIcon('volume-low', { class: 'media-icon media-icon--volume-low' })} + ${renderIcon('volume-high', { class: 'media-icon media-icon--volume-high' })} + + + + + + + + + + + + + ${renderIcon('captions-off', { class: 'media-icon media-icon--captions-off' })} + ${renderIcon('captions-on', { class: 'media-icon media-icon--captions-on' })} + + + + + ${renderIcon('cast-enter', { class: 'media-icon media-icon--cast-enter' })} + ${renderIcon('cast-exit', { class: 'media-icon media-icon--cast-exit' })} + + + + + ${renderIcon('pip-enter', { class: 'media-icon media-icon--pip-enter' })} + ${renderIcon('pip-exit', { class: 'media-icon media-icon--pip-exit' })} + + + + + ${renderIcon('fullscreen-enter', { class: 'media-icon media-icon--fullscreen-enter' })} + ${renderIcon('fullscreen-exit', { class: 'media-icon media-icon--fullscreen-exit' })} + + +
+
+
+ +
+ + + + + + + + + + + + + + + +
+ `; +} + +export class LiveVideoSkinElement extends SkinElement { + static readonly tagName = 'live-video-skin'; + static styles = createShadowStyle(styles); + static template = createTemplate(getTemplateHTML()); +} + +safeDefine(LiveVideoSkinElement); + +declare global { + interface HTMLElementTagNameMap { + [LiveVideoSkinElement.tagName]: LiveVideoSkinElement; + } +} diff --git a/packages/html/src/presets/live-audio.ts b/packages/html/src/presets/live-audio.ts new file mode 100644 index 00000000..cc4673a8 --- /dev/null +++ b/packages/html/src/presets/live-audio.ts @@ -0,0 +1,6 @@ +/** Live audio player preset — same features as `audio` with a skin that omits duration / current-time displays. */ +export { liveAudioFeatures } from '@videojs/core/dom'; +export { MinimalLiveAudioSkinElement } from '../define/live-audio/minimal-skin'; +export { MinimalLiveAudioSkinTailwindElement } from '../define/live-audio/minimal-skin.tailwind'; +export { LiveAudioSkinElement } from '../define/live-audio/skin'; +export { LiveAudioSkinTailwindElement } from '../define/live-audio/skin.tailwind'; diff --git a/packages/html/src/presets/live-video.ts b/packages/html/src/presets/live-video.ts new file mode 100644 index 00000000..2bd5fc46 --- /dev/null +++ b/packages/html/src/presets/live-video.ts @@ -0,0 +1,6 @@ +/** Live video player preset — same features as `video` with a skin that omits duration / current-time displays. */ +export { liveVideoFeatures } from '@videojs/core/dom'; +export { MinimalLiveVideoSkinElement } from '../define/live-video/minimal-skin'; +export { MinimalLiveVideoSkinTailwindElement } from '../define/live-video/minimal-skin.tailwind'; +export { LiveVideoSkinElement } from '../define/live-video/skin'; +export { LiveVideoSkinTailwindElement } from '../define/live-video/skin.tailwind'; diff --git a/packages/html/tsdown.cdn.config.ts b/packages/html/tsdown.cdn.config.ts index eb7a1ce3..0494a531 100644 --- a/packages/html/tsdown.cdn.config.ts +++ b/packages/html/tsdown.cdn.config.ts @@ -17,6 +17,8 @@ const presets = [ 'video-minimal', 'video-ui', 'video-minimal-ui', + 'live-video', + 'live-video-minimal', 'audio', 'audio-minimal', 'audio-ui', diff --git a/packages/react/package.json b/packages/react/package.json index 24cdc37e..ef4e4d3b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -59,6 +59,28 @@ "types": "./dist/dev/presets/background/*.d.ts", "development": "./dist/dev/presets/background/*.js", "default": "./dist/default/presets/background/*.js" + }, + "./live-video": { + "types": "./dist/dev/presets/live-video/index.d.ts", + "development": "./dist/dev/presets/live-video/index.js", + "default": "./dist/default/presets/live-video/index.js" + }, + "./live-video/*.css": "./dist/default/presets/live-video/*.css", + "./live-video/*": { + "types": "./dist/dev/presets/live-video/*.d.ts", + "development": "./dist/dev/presets/live-video/*.js", + "default": "./dist/default/presets/live-video/*.js" + }, + "./live-audio": { + "types": "./dist/dev/presets/live-audio/index.d.ts", + "development": "./dist/dev/presets/live-audio/index.js", + "default": "./dist/default/presets/live-audio/index.js" + }, + "./live-audio/*.css": "./dist/default/presets/live-audio/*.css", + "./live-audio/*": { + "types": "./dist/dev/presets/live-audio/*.d.ts", + "development": "./dist/dev/presets/live-audio/*.js", + "default": "./dist/default/presets/live-audio/*.js" } }, "scripts": { diff --git a/packages/react/src/presets/live-audio/index.ts b/packages/react/src/presets/live-audio/index.ts new file mode 100644 index 00000000..03c81b66 --- /dev/null +++ b/packages/react/src/presets/live-audio/index.ts @@ -0,0 +1,7 @@ +/** Live audio player preset — same features as `audio` with a skin that omits duration / current-time displays. */ +export { liveAudioFeatures } from '@videojs/core/dom'; +export { Audio, type AudioProps } from '@/media/audio'; +export * from './minimal-skin'; +export * from './minimal-skin.tailwind'; +export * from './skin'; +export * from './skin.tailwind'; diff --git a/packages/react/src/presets/live-audio/minimal-skin.css b/packages/react/src/presets/live-audio/minimal-skin.css new file mode 100644 index 00000000..f7064f30 --- /dev/null +++ b/packages/react/src/presets/live-audio/minimal-skin.css @@ -0,0 +1 @@ +@import "@videojs/skins/minimal/css/audio.css"; diff --git a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx new file mode 100644 index 00000000..ad65a1df --- /dev/null +++ b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx @@ -0,0 +1,149 @@ +import { + PauseIcon, + PlayIcon, + RestartIcon, + VolumeHighIcon, + VolumeLowIcon, + VolumeOffIcon, +} from '@videojs/icons/react/minimal'; +import { + button, + buttonGroup, + controls, + error, + icon, + iconState, + popup, + root, + slider, +} from '@videojs/skins/minimal/tailwind/audio.tailwind'; +import { cn } from '@videojs/utils/style'; +import { type ComponentProps, forwardRef, type ReactNode } from 'react'; +import { Container, usePlayer } from '@/player/context'; +import { ErrorDialog } from '@/ui/error-dialog'; +import { MuteButton } from '@/ui/mute-button'; +import { PlayButton } from '@/ui/play-button'; +import { Popover } from '@/ui/popover'; +import { Tooltip } from '@/ui/tooltip'; +import { VolumeSlider } from '@/ui/volume-slider'; +import type { MinimalLiveAudioSkinProps } from './minimal-skin'; + +const Button = forwardRef>(function Button({ className, ...props }, ref) { + return ( +