diff --git a/apps/sandbox/app/shared/html/sandbox-state.ts b/apps/sandbox/app/shared/html/sandbox-state.ts index 0906da5a2..6300dbd9d 100644 --- a/apps/sandbox/app/shared/html/sandbox-state.ts +++ b/apps/sandbox/app/shared/html/sandbox-state.ts @@ -1,4 +1,12 @@ -import { getInitialSkin, getInitialSource } from '@app/shared/sandbox-listener'; +import { + getInitialAutoplay, + getInitialLoop, + getInitialMuted, + getInitialPreload, + getInitialSkin, + getInitialSource, + type PreloadValue, +} from '@app/shared/sandbox-listener'; import type { SourceId } from '@app/shared/sources'; import type { Skin, Styling } from '@app/types'; @@ -10,6 +18,10 @@ export type HtmlSandboxState = { skin: Skin; source: SourceId; styling: Styling; + autoplay: boolean; + muted: boolean; + loop: boolean; + preload: PreloadValue; }; export function createHtmlSandboxState(audioOnly?: boolean): HtmlSandboxState { @@ -17,9 +29,25 @@ export function createHtmlSandboxState(audioOnly?: boolean): HtmlSandboxState { skin: getInitialSkin(), source: getInitialSource(audioOnly), styling: getInitialStyling(), + autoplay: getInitialAutoplay(), + muted: getInitialMuted(), + loop: getInitialLoop(), + preload: getInitialPreload(), }; } +/** Render the user-controlled media attributes (autoplay/muted/loop/preload) as HTML attributes. */ +export function renderMediaAttrs(state: HtmlSandboxState): string { + return [ + state.autoplay ? 'autoplay' : '', + state.muted ? 'muted' : '', + state.loop ? 'loop' : '', + `preload="${state.preload}"`, + ] + .filter(Boolean) + .join(' '); +} + export function createLatestLoader() { let loadVersion = 0; diff --git a/apps/sandbox/app/shared/react/providers.ts b/apps/sandbox/app/shared/react/providers.ts index 65be1ebed..b3cad8b85 100644 --- a/apps/sandbox/app/shared/react/providers.ts +++ b/apps/sandbox/app/shared/react/providers.ts @@ -1,10 +1,10 @@ import { createPlayer } from '@videojs/react'; import { audioFeatures } from '@videojs/react/audio'; import { backgroundFeatures } from '@videojs/react/background'; +import { liveAudioFeatures } from '@videojs/react/live-audio'; +import { liveVideoFeatures } from '@videojs/react/live-video'; 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, }); @@ -16,3 +16,13 @@ export const { Provider: AudioProvider } = createPlayer({ export const { Provider: BackgroundVideoProvider } = createPlayer({ features: backgroundFeatures, }); + +// Live providers register `liveFeature` so the LiveButton can read +// `liveEdgeStart` / `targetLiveWindow` and seek to the live edge. +export const { Provider: LiveVideoProvider } = createPlayer({ + features: liveVideoFeatures, +}); + +export const { Provider: LiveAudioProvider } = createPlayer({ + features: liveAudioFeatures, +}); diff --git a/apps/sandbox/app/shared/react/use-autoplay.ts b/apps/sandbox/app/shared/react/use-autoplay.ts new file mode 100644 index 000000000..ee411e6b9 --- /dev/null +++ b/apps/sandbox/app/shared/react/use-autoplay.ts @@ -0,0 +1,8 @@ +import { getInitialAutoplay, onAutoplayChange } from '@app/shared/sandbox-listener'; +import { useEffect, useState } from 'react'; + +export function useAutoplay(): boolean { + const [autoplay, setAutoplay] = useState(getInitialAutoplay); + useEffect(() => onAutoplayChange(setAutoplay), []); + return autoplay; +} diff --git a/apps/sandbox/app/shared/react/use-loop.ts b/apps/sandbox/app/shared/react/use-loop.ts new file mode 100644 index 000000000..9587b2fd5 --- /dev/null +++ b/apps/sandbox/app/shared/react/use-loop.ts @@ -0,0 +1,8 @@ +import { getInitialLoop, onLoopChange } from '@app/shared/sandbox-listener'; +import { useEffect, useState } from 'react'; + +export function useLoop(): boolean { + const [loop, setLoop] = useState(getInitialLoop); + useEffect(() => onLoopChange(setLoop), []); + return loop; +} diff --git a/apps/sandbox/app/shared/react/use-muted.ts b/apps/sandbox/app/shared/react/use-muted.ts new file mode 100644 index 000000000..8591f53c1 --- /dev/null +++ b/apps/sandbox/app/shared/react/use-muted.ts @@ -0,0 +1,8 @@ +import { getInitialMuted, onMutedChange } from '@app/shared/sandbox-listener'; +import { useEffect, useState } from 'react'; + +export function useMuted(): boolean { + const [muted, setMuted] = useState(getInitialMuted); + useEffect(() => onMutedChange(setMuted), []); + return muted; +} diff --git a/apps/sandbox/app/shared/react/use-preload.ts b/apps/sandbox/app/shared/react/use-preload.ts new file mode 100644 index 000000000..5cff41d73 --- /dev/null +++ b/apps/sandbox/app/shared/react/use-preload.ts @@ -0,0 +1,8 @@ +import { getInitialPreload, onPreloadChange, type PreloadValue } from '@app/shared/sandbox-listener'; +import { useEffect, useState } from 'react'; + +export function usePreload(): PreloadValue { + const [preload, setPreload] = useState(getInitialPreload); + useEffect(() => onPreloadChange(setPreload), []); + return preload; +} diff --git a/apps/sandbox/app/shared/sandbox-listener.ts b/apps/sandbox/app/shared/sandbox-listener.ts index b2d38aa9e..90a242bcf 100644 --- a/apps/sandbox/app/shared/sandbox-listener.ts +++ b/apps/sandbox/app/shared/sandbox-listener.ts @@ -2,6 +2,10 @@ import { SKINS } from '@app/constants'; import type { Skin } from '@app/types'; import { DEFAULT_AUDIO_SOURCE, SOURCES, type SourceId } from './sources'; +export const PRELOAD_VALUES = ['none', 'metadata', 'auto'] as const; +export type PreloadValue = (typeof PRELOAD_VALUES)[number]; +export const DEFAULT_PRELOAD: PreloadValue = 'metadata'; + const params = new URLSearchParams(window.location.search); function readSkin(): Skin { @@ -16,8 +20,21 @@ function readSource(): SourceId { return source && source in SOURCES ? (source as SourceId) : 'hls-1'; } +function readBoolean(name: string): boolean { + return params.get(name) === '1'; +} + +function readPreload(): PreloadValue { + const value = params.get('preload'); + return PRELOAD_VALUES.includes(value as PreloadValue) ? (value as PreloadValue) : DEFAULT_PRELOAD; +} + let currentSkin = readSkin(); let currentSource = readSource(); +let currentAutoplay = readBoolean('autoplay'); +let currentMuted = readBoolean('muted'); +let currentLoop = readBoolean('loop'); +let currentPreload = readPreload(); export function getInitialSkin(): Skin { return currentSkin; @@ -62,3 +79,79 @@ export function onSourceChange(callback: (source: SourceId) => void): () => void window.removeEventListener('message', handler); }; } + +export function getInitialAutoplay(): boolean { + return currentAutoplay; +} + +export function onAutoplayChange(callback: (autoplay: boolean) => void): () => void { + const handler = (event: MessageEvent) => { + if (event.data?.type !== 'autoplay-change' || typeof event.data.autoplay !== 'boolean') return; + + currentAutoplay = event.data.autoplay; + callback(currentAutoplay); + }; + + window.addEventListener('message', handler); + + return () => { + window.removeEventListener('message', handler); + }; +} + +export function getInitialMuted(): boolean { + return currentMuted; +} + +export function onMutedChange(callback: (muted: boolean) => void): () => void { + const handler = (event: MessageEvent) => { + if (event.data?.type !== 'muted-change' || typeof event.data.muted !== 'boolean') return; + + currentMuted = event.data.muted; + callback(currentMuted); + }; + + window.addEventListener('message', handler); + + return () => { + window.removeEventListener('message', handler); + }; +} + +export function getInitialLoop(): boolean { + return currentLoop; +} + +export function onLoopChange(callback: (loop: boolean) => void): () => void { + const handler = (event: MessageEvent) => { + if (event.data?.type !== 'loop-change' || typeof event.data.loop !== 'boolean') return; + + currentLoop = event.data.loop; + callback(currentLoop); + }; + + window.addEventListener('message', handler); + + return () => { + window.removeEventListener('message', handler); + }; +} + +export function getInitialPreload(): PreloadValue { + return currentPreload; +} + +export function onPreloadChange(callback: (preload: PreloadValue) => void): () => void { + const handler = (event: MessageEvent) => { + if (event.data?.type !== 'preload-change' || !PRELOAD_VALUES.includes(event.data.preload)) return; + + currentPreload = event.data.preload; + callback(currentPreload); + }; + + window.addEventListener('message', handler); + + return () => { + window.removeEventListener('message', handler); + }; +} diff --git a/apps/sandbox/app/shell/app.tsx b/apps/sandbox/app/shell/app.tsx index e25fb1849..5335902ed 100644 --- a/apps/sandbox/app/shell/app.tsx +++ b/apps/sandbox/app/shell/app.tsx @@ -1,4 +1,5 @@ import { PLATFORMS, PRESETS, STYLINGS } from '@app/constants'; +import { DEFAULT_PRELOAD, PRELOAD_VALUES, type PreloadValue } from '@app/shared/sandbox-listener'; import type { SourceId } from '@app/shared/sources'; import { DASH_SOURCE_IDS, @@ -22,12 +23,17 @@ function getPagePath(platform: Platform, preset: Preset): string { function readParams() { const params = new URLSearchParams(location.search); + const preload = params.get('preload'); return { platform: (params.get('platform') ?? 'html') as Platform, styling: (params.get('styling') ?? 'css') as Styling, preset: (params.get('preset') ?? 'video') as Preset, skin: (params.get('skin') ?? 'default') as 'default' | 'minimal', source: (params.get('source') ?? 'hls-1') as SourceId, + autoplay: params.get('autoplay') === '1', + muted: params.get('muted') === '1', + loop: params.get('loop') === '1', + preload: PRELOAD_VALUES.includes(preload as PreloadValue) ? (preload as PreloadValue) : DEFAULT_PRELOAD, }; } @@ -38,15 +44,29 @@ export function App() { const [preset, setPreset] = useState(initial.preset); const [skin, setSkin] = useState(initial.skin); const [source, setSource] = useState(initial.source); + const [autoplay, setAutoplay] = useState(initial.autoplay); + const [muted, setMuted] = useState(initial.muted); + const [loop, setLoop] = useState(initial.loop); + const [preload, setPreload] = useState(initial.preload); const iframeRef = useRef(null); const pagePath = getPagePath(platform, preset); - // Keep the URL in sync with all state (including skin + source) + // Keep the URL in sync with all state. useEffect(() => { - const params = new URLSearchParams({ platform, styling, preset, skin, source }); + const params = new URLSearchParams({ + platform, + styling, + preset, + skin, + source, + autoplay: autoplay ? '1' : '0', + muted: muted ? '1' : '0', + loop: loop ? '1' : '0', + preload, + }); history.replaceState(null, '', `/?${params}`); - }, [platform, styling, preset, skin, source]); + }, [platform, styling, preset, skin, source, autoplay, muted, loop, preload]); useEffect(() => { iframeRef.current?.contentWindow?.postMessage({ type: 'skin-change', skin }, '*'); @@ -56,6 +76,22 @@ export function App() { iframeRef.current?.contentWindow?.postMessage({ type: 'source-change', source }, '*'); }, [source]); + useEffect(() => { + iframeRef.current?.contentWindow?.postMessage({ type: 'autoplay-change', autoplay }, '*'); + }, [autoplay]); + + useEffect(() => { + iframeRef.current?.contentWindow?.postMessage({ type: 'muted-change', muted }, '*'); + }, [muted]); + + useEffect(() => { + iframeRef.current?.contentWindow?.postMessage({ type: 'loop-change', loop }, '*'); + }, [loop]); + + useEffect(() => { + iframeRef.current?.contentWindow?.postMessage({ type: 'preload-change', preload }, '*'); + }, [preload]); + // Constrain source to MP4 when switching to audio useEffect(() => { if (preset === 'audio' && SOURCES[source].type !== 'mp4') { @@ -102,6 +138,14 @@ export function App() { onSkinChange={setSkin} source={source} onSourceChange={handleSourceChange} + autoplay={autoplay} + onAutoplayChange={setAutoplay} + muted={muted} + onMutedChange={setMuted} + loop={loop} + onLoopChange={setLoop} + preload={preload} + onPreloadChange={setPreload} availableSources={availableSources} isBackgroundVideo={preset === 'background-video'} isSimpleHlsVideo={preset === 'simple-hls-video'} @@ -120,6 +164,10 @@ export function App() { skin={skin} styling={styling} source={source} + autoplay={autoplay} + muted={muted} + loop={loop} + preload={preload} /> ); diff --git a/apps/sandbox/app/shell/navbar.tsx b/apps/sandbox/app/shell/navbar.tsx index 04416306e..d540aa005 100644 --- a/apps/sandbox/app/shell/navbar.tsx +++ b/apps/sandbox/app/shell/navbar.tsx @@ -1,6 +1,8 @@ import type { SKINS } from '@app/constants'; +import { PRELOAD_VALUES, type PreloadValue } from '@app/shared/sandbox-listener'; import type { SourceId } from '@app/shared/sources'; import type { Platform, Preset, Skin, Styling } from '@app/types'; +import { useEffect, useId, useRef, useState } from 'react'; type NavbarProps = { platform: Platform; @@ -13,6 +15,14 @@ type NavbarProps = { onSkinChange: (value: Skin) => void; source: SourceId; onSourceChange: (value: string) => void; + autoplay: boolean; + onAutoplayChange: (value: boolean) => void; + muted: boolean; + onMutedChange: (value: boolean) => void; + loop: boolean; + onLoopChange: (value: boolean) => void; + preload: PreloadValue; + onPreloadChange: (value: PreloadValue) => void; availableSources: readonly SourceId[]; isBackgroundVideo: boolean; isSimpleHlsVideo: boolean; @@ -55,6 +65,14 @@ export function Navbar({ onSkinChange, source, onSourceChange, + autoplay, + onAutoplayChange, + muted, + onMutedChange, + loop, + onLoopChange, + preload, + onPreloadChange, availableSources, isBackgroundVideo, isSimpleHlsVideo, @@ -122,7 +140,17 @@ export function Navbar({ /> -
+
+ void; + muted: boolean; + onMutedChange: (value: boolean) => void; + loop: boolean; + onLoopChange: (value: boolean) => void; + preload: PreloadValue; + onPreloadChange: (value: PreloadValue) => void; +}; + +function SettingsMenu({ + autoplay, + onAutoplayChange, + muted, + onMutedChange, + loop, + onLoopChange, + preload, + onPreloadChange, +}: SettingsMenuProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const menuId = useId(); + const autoplayId = useId(); + const mutedId = useId(); + const loopId = useId(); + const preloadId = useId(); + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: PointerEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + // Clicks inside the preview iframe don't bubble to the parent document, so + // also close when the parent window loses focus (e.g. iframe takes focus). + const handleBlur = () => setOpen(false); + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + window.addEventListener('blur', handleBlur); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('blur', handleBlur); + }; + }, [open]); + + return ( +
+ + {open && ( + + )} +
+ ); +} + +type CheckboxItemProps = { + id: string; + label: string; + checked: boolean; + onChange: (value: boolean) => void; +}; + +function CheckboxItem({ id, label, checked, onChange }: CheckboxItemProps) { + return ( + <> + + onChange(event.target.checked)} + className="justify-self-start size-3.5 rounded border-zinc-300 dark:border-zinc-700 accent-zinc-950 dark:accent-zinc-50 cursor-pointer" + /> + + ); +} + +type SelectItemProps = { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + options: SelectOption[]; +}; + +function SelectItem({ id, label, value, onChange, options }: SelectItemProps) { + return ( + <> + +
+ + +
+ + ); +} + type SelectOption = { value: string; label: string; diff --git a/apps/sandbox/app/shell/preview.tsx b/apps/sandbox/app/shell/preview.tsx index 28691ba16..53af2f2e3 100644 --- a/apps/sandbox/app/shell/preview.tsx +++ b/apps/sandbox/app/shell/preview.tsx @@ -1,3 +1,4 @@ +import type { PreloadValue } from '@app/shared/sandbox-listener'; import type { SourceId } from '@app/shared/sources'; import type { Preset, Skin, Styling } from '@app/types'; import { forwardRef, useState } from 'react'; @@ -8,19 +9,34 @@ type PreviewProps = { skin: Skin; styling: Styling; source: SourceId; + autoplay: boolean; + muted: boolean; + loop: boolean; + preload: PreloadValue; }; export const Preview = forwardRef(function Preview( - { pagePath, preset, skin, styling, source }, + { pagePath, preset, skin, styling, source, autoplay, muted, loop, preload }, ref ) { - const [iframeUrl] = useState( - () => - `${pagePath}?preset=${encodeURIComponent(preset)}&skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}&source=${encodeURIComponent(source)}` - ); - const openUrl = - `${pagePath}?preset=${encodeURIComponent(preset)}&skin=${encodeURIComponent(skin)}&styling=${encodeURIComponent(styling)}` + - `&source=${encodeURIComponent(source)}`; + const buildUrl = (base: string) => { + const params = new URLSearchParams({ + preset, + skin, + styling, + source, + autoplay: autoplay ? '1' : '0', + muted: muted ? '1' : '0', + loop: loop ? '1' : '0', + preload, + }); + return `${base}?${params}`; + }; + + // Capture the initial query so the iframe doesn't reload when autoplay/muted + // toggle — those changes are streamed in via postMessage. + const [iframeUrl] = useState(() => buildUrl(pagePath)); + const openUrl = buildUrl(pagePath); return (
diff --git a/apps/sandbox/templates/cdn/main.ts b/apps/sandbox/templates/cdn/main.ts index 03a535a8c..e0d6a82bc 100644 --- a/apps/sandbox/templates/cdn/main.ts +++ b/apps/sandbox/templates/cdn/main.ts @@ -1,9 +1,16 @@ import '@app/styles.css'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; 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 { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { BACKGROUND_VIDEO_SRC, getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources'; import type { Preset, Skin } from '@app/types'; @@ -73,10 +80,10 @@ async function loadCdnMedia(preset: Preset) { // Rendering — produces the exact HTML markup the installation builder generates. // --------------------------------------------------------------------------- -function getPlayerTag(preset: Preset): string { +function getPlayerTag(preset: Preset, live: boolean): string { if (preset === 'background-video') return 'background-video-player'; - if (preset === 'audio' || preset === 'mux-audio') return 'audio-player'; - return 'video-player'; + if (preset === 'audio' || preset === 'mux-audio') return live ? 'live-audio-player' : 'audio-player'; + return live ? 'live-video-player' : 'video-player'; } function getSkinTag(preset: Preset, skin: Skin, live: boolean): string { @@ -135,7 +142,7 @@ async function render() { loadStylesheets(preset, state.skin); const root = document.getElementById('root')!; - const playerTag = getPlayerTag(preset); + const playerTag = getPlayerTag(preset, live); const skinTag = getSkinTag(preset, state.skin, live); const mediaTag = getMediaTag(preset); const source = SOURCES[state.source]; @@ -143,7 +150,7 @@ async function render() { 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' : ''; + const mediaAttrs = renderMediaAttrs(state); // Background video needs viewport dimensions instead of flex centering. if (preset === 'background-video') { @@ -167,7 +174,7 @@ async function render() {
<${playerTag}> <${skinTag}> - <${mediaTag} ${sourceAttr}> + <${mediaTag} ${sourceAttr} ${mediaAttrs}>
@@ -178,7 +185,7 @@ async function render() { root.innerHTML = html` <${playerTag}> <${skinTag} class="aspect-video max-w-4xl mx-auto"> - <${mediaTag} ${sourceAttr} ${liveAttrs} playsinline crossorigin="anonymous"> + <${mediaTag} ${sourceAttr} ${mediaAttrs} playsinline crossorigin="anonymous"> ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} @@ -198,3 +205,23 @@ onSourceChange((source) => { state.source = source; render(); }); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); + +onPreloadChange((preload) => { + state.preload = preload; + render(); +}); diff --git a/apps/sandbox/templates/html-audio/main.ts b/apps/sandbox/templates/html-audio/main.ts index 6054ba313..9e84b2394 100644 --- a/apps/sandbox/templates/html-audio/main.ts +++ b/apps/sandbox/templates/html-audio/main.ts @@ -1,8 +1,15 @@ import '@app/styles.css'; import '@videojs/html/audio/player'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadAudioSkinTag } from '@app/shared/html/skins'; -import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -14,11 +21,13 @@ async function render() { const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling)); if (!tag) return; + const mediaAttrs = renderMediaAttrs(state); + document.getElementById('root')!.innerHTML = html`
<${tag}> - +
@@ -36,3 +45,23 @@ onSourceChange((source) => { state.source = source; render(); }); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); + +onPreloadChange((preload) => { + state.preload = preload; + render(); +}); diff --git a/apps/sandbox/templates/html-dash-video/main.ts b/apps/sandbox/templates/html-dash-video/main.ts index 3e28c698a..6b4338ee0 100644 --- a/apps/sandbox/templates/html-dash-video/main.ts +++ b/apps/sandbox/templates/html-dash-video/main.ts @@ -1,10 +1,17 @@ import '@app/styles.css'; import '@videojs/html/video/player'; import '@videojs/html/media/dash-video'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadVideoSkinTag } from '@app/shared/html/skins'; import { renderStoryboard } from '@app/shared/html/storyboard'; -import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -18,11 +25,12 @@ async function render() { const storyboard = getStoryboardSrc(state.source); const poster = getPosterSrc(state.source); + const mediaAttrs = renderMediaAttrs(state); document.getElementById('root')!.innerHTML = html` <${tag} class="aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} @@ -42,3 +50,23 @@ onSourceChange((source) => { state.source = source; render(); }); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); + +onPreloadChange((preload) => { + state.preload = preload; + render(); +}); diff --git a/apps/sandbox/templates/html-hls-video/main.ts b/apps/sandbox/templates/html-hls-video/main.ts index 2bbdb93cd..43adf4a77 100644 --- a/apps/sandbox/templates/html-hls-video/main.ts +++ b/apps/sandbox/templates/html-hls-video/main.ts @@ -1,10 +1,17 @@ import '@app/styles.css'; import '@videojs/html/video/player'; import '@videojs/html/media/hls-video'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadVideoSkinTag } from '@app/shared/html/skins'; import { renderStoryboard } from '@app/shared/html/storyboard'; -import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -19,17 +26,18 @@ async function render() { const storyboard = getStoryboardSrc(state.source); const poster = getPosterSrc(state.source); - const liveAttrs = live ? 'autoplay muted' : ''; + const mediaAttrs = renderMediaAttrs(state); + const playerTag = live ? 'live-video-player' : 'video-player'; document.getElementById('root')!.innerHTML = html` - + <${playerTag}> <${tag} class="aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} - + `; } @@ -44,3 +52,23 @@ onSourceChange((source) => { state.source = source; render(); }); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); + +onPreloadChange((preload) => { + state.preload = preload; + render(); +}); diff --git a/apps/sandbox/templates/html-mux-audio/main.ts b/apps/sandbox/templates/html-mux-audio/main.ts index 3a2e45bfc..1120e6bec 100644 --- a/apps/sandbox/templates/html-mux-audio/main.ts +++ b/apps/sandbox/templates/html-mux-audio/main.ts @@ -1,9 +1,16 @@ import '@app/styles.css'; import '@videojs/html/audio/player'; import '@videojs/html/media/mux-audio'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadAudioSkinTag } from '@app/shared/html/skins'; -import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -15,11 +22,13 @@ async function render() { const tag = await loadLatest(() => loadAudioSkinTag(state.skin, state.styling)); if (!tag) return; + const mediaAttrs = renderMediaAttrs(state); + document.getElementById('root')!.innerHTML = html`
<${tag}> - +
@@ -37,3 +46,23 @@ onSourceChange((source) => { state.source = source; render(); }); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); + +onPreloadChange((preload) => { + state.preload = preload; + render(); +}); diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts index b90b0cbc5..317b3fc04 100644 --- a/apps/sandbox/templates/html-mux-video/main.ts +++ b/apps/sandbox/templates/html-mux-video/main.ts @@ -1,10 +1,17 @@ import '@app/styles.css'; import '@videojs/html/video/player'; import '@videojs/html/media/mux-video'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadVideoSkinTag } from '@app/shared/html/skins'; import { renderStoryboard } from '@app/shared/html/storyboard'; -import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -19,17 +26,18 @@ async function render() { const storyboard = getStoryboardSrc(state.source); const poster = getPosterSrc(state.source); - const liveAttrs = live ? 'autoplay muted' : ''; + const mediaAttrs = renderMediaAttrs(state); + const playerTag = live ? 'live-video-player' : 'video-player'; document.getElementById('root')!.innerHTML = html` - + <${playerTag}> <${tag} class="aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} - + `; } @@ -44,3 +52,23 @@ onSourceChange((source) => { state.source = source; render(); }); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); + +onPreloadChange((preload) => { + state.preload = preload; + render(); +}); diff --git a/apps/sandbox/templates/html-native-hls-video/main.ts b/apps/sandbox/templates/html-native-hls-video/main.ts index e62da463d..6c0b4941a 100644 --- a/apps/sandbox/templates/html-native-hls-video/main.ts +++ b/apps/sandbox/templates/html-native-hls-video/main.ts @@ -1,10 +1,17 @@ import '@app/styles.css'; import '@videojs/html/video/player'; import '@videojs/html/media/native-hls-video'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadVideoSkinTag } from '@app/shared/html/skins'; import { renderStoryboard } from '@app/shared/html/storyboard'; -import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -19,17 +26,18 @@ async function render() { const storyboard = getStoryboardSrc(state.source); const poster = getPosterSrc(state.source); - const liveAttrs = live ? 'autoplay muted' : ''; + const mediaAttrs = renderMediaAttrs(state); + const playerTag = live ? 'live-video-player' : 'video-player'; document.getElementById('root')!.innerHTML = html` - + <${playerTag}> <${tag} class="w-full aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} - + `; } @@ -44,3 +52,23 @@ onSourceChange((source) => { state.source = source; render(); }); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); + +onPreloadChange((preload) => { + state.preload = preload; + render(); +}); diff --git a/apps/sandbox/templates/html-simple-hls-video/main.ts b/apps/sandbox/templates/html-simple-hls-video/main.ts index 21c820aa2..e1dd163dd 100644 --- a/apps/sandbox/templates/html-simple-hls-video/main.ts +++ b/apps/sandbox/templates/html-simple-hls-video/main.ts @@ -1,10 +1,17 @@ import '@app/styles.css'; import '@videojs/html/video/player'; import '@videojs/html/media/simple-hls-video'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadVideoSkinTag } from '@app/shared/html/skins'; import { renderStoryboard } from '@app/shared/html/storyboard'; -import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -19,17 +26,18 @@ async function render() { const storyboard = getStoryboardSrc(state.source); const poster = getPosterSrc(state.source); - const liveAttrs = live ? 'autoplay muted' : ''; + const mediaAttrs = renderMediaAttrs(state); + const playerTag = live ? 'live-video-player' : 'video-player'; document.getElementById('root')!.innerHTML = html` - + <${playerTag}> <${tag} class="aspect-video max-w-4xl mx-auto"> - + ${renderStoryboard(storyboard)} ${poster ? html`Video poster` : ''} - + `; } @@ -44,3 +52,23 @@ onSourceChange((source) => { state.source = source; render(); }); + +onAutoplayChange((autoplay) => { + state.autoplay = autoplay; + render(); +}); + +onMutedChange((muted) => { + state.muted = muted; + render(); +}); + +onLoopChange((loop) => { + state.loop = loop; + render(); +}); + +onPreloadChange((preload) => { + state.preload = preload; + render(); +}); diff --git a/apps/sandbox/templates/html-video/main.ts b/apps/sandbox/templates/html-video/main.ts index c82fb7014..76f31ddd0 100644 --- a/apps/sandbox/templates/html-video/main.ts +++ b/apps/sandbox/templates/html-video/main.ts @@ -1,9 +1,16 @@ import '@app/styles.css'; import '@videojs/html/video/player'; -import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadVideoSkinTag } from '@app/shared/html/skins'; import { renderStoryboard } from '@app/shared/html/storyboard'; -import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { + onAutoplayChange, + onLoopChange, + onMutedChange, + onPreloadChange, + onSkinChange, + onSourceChange, +} from '@app/shared/sandbox-listener'; import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -17,11 +24,12 @@ async function render() { const storyboard = getStoryboardSrc(state.source); const poster = getPosterSrc(state.source); + const mediaAttrs = renderMediaAttrs(state); document.getElementById('root')!.innerHTML = html` <${tag} class="aspect-video max-w-4xl mx-auto"> -
diff --git a/packages/html/src/define/live-audio/minimal-skin.ts b/packages/html/src/define/live-audio/minimal-skin.ts index 63f067888..7c616dd4c 100644 --- a/packages/html/src/define/live-audio/minimal-skin.ts +++ b/packages/html/src/define/live-audio/minimal-skin.ts @@ -4,8 +4,8 @@ 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'; +// Register the live audio player, container, and minimal UI custom elements. +import './minimal-ui'; function getTemplateHTML() { return /*html*/ ` @@ -35,6 +35,8 @@ function getTemplateHTML() { ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + +
diff --git a/packages/html/src/define/live-audio/minimal-ui.ts b/packages/html/src/define/live-audio/minimal-ui.ts new file mode 100644 index 000000000..40a907479 --- /dev/null +++ b/packages/html/src/define/live-audio/minimal-ui.ts @@ -0,0 +1,35 @@ +// Registers the live audio player, container, and all audio UI custom +// elements used by the minimal skin without creating a skin element. Use +// this entry when building an ejected (light DOM) player layout for live +// HLS / DASH streams. +import { MediaContainerElement } from '../../media/container-element'; +import { LiveButtonElement } from '../../ui/live-button/live-button-element'; +import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; +import { PlayButtonElement } from '../../ui/play-button/play-button-element'; +import { PopoverElement } from '../../ui/popover/popover-element'; +import { TooltipElement } from '../../ui/tooltip/tooltip-element'; +import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { safeDefine } from '../safe-define'; +import { defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; + +// Value import — player.ts body runs before this module's body. +import { LiveAudioPlayerElement } from './player'; + +// ── Registration (providers / parents first) ──────────────────────────── + +safeDefine(LiveAudioPlayerElement); +safeDefine(MediaContainerElement); + +// Compound groups. +defineErrorDialog(); +defineTimeSlider(); +defineVolumeSlider(); +defineTime(); + +// Standalone elements. +safeDefine(LiveButtonElement); +safeDefine(MuteButtonElement); +safeDefine(PlayButtonElement); +safeDefine(PopoverElement); +safeDefine(TooltipElement); +safeDefine(TooltipGroupElement); diff --git a/packages/html/src/define/live-audio/player.ts b/packages/html/src/define/live-audio/player.ts new file mode 100644 index 000000000..990dcfa5b --- /dev/null +++ b/packages/html/src/define/live-audio/player.ts @@ -0,0 +1,23 @@ +import { liveAudioFeatures } from '@videojs/core/dom'; +import { MediaContainerElement } from '../../media/container-element'; +import { createPlayer } from '../../player/create-player'; +import { MediaElement } from '../../ui/media-element'; +import { safeDefine } from '../safe-define'; + +const { ProviderMixin } = createPlayer({ + features: liveAudioFeatures, +}); + +export class LiveAudioPlayerElement extends ProviderMixin(MediaElement) { + static readonly tagName = 'live-audio-player'; +} + +// Provider must be defined before consumer for context handshake during upgrade. +safeDefine(LiveAudioPlayerElement); +safeDefine(MediaContainerElement); + +declare global { + interface HTMLElementTagNameMap { + [LiveAudioPlayerElement.tagName]: LiveAudioPlayerElement; + } +} diff --git a/packages/html/src/define/live-audio/skin.tailwind.ts b/packages/html/src/define/live-audio/skin.tailwind.ts index a84560738..47eba5776 100644 --- a/packages/html/src/define/live-audio/skin.tailwind.ts +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -15,8 +15,8 @@ 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'; +// Register the live audio player, container, and all UI custom elements. +import './ui'; function getTemplateHTML() { return /*html*/ ` @@ -46,6 +46,8 @@ function getTemplateHTML() { ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + diff --git a/packages/html/src/define/live-audio/skin.ts b/packages/html/src/define/live-audio/skin.ts index ee0cee7aa..9a7d51c5c 100644 --- a/packages/html/src/define/live-audio/skin.ts +++ b/packages/html/src/define/live-audio/skin.ts @@ -4,8 +4,8 @@ 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'; +// Register the live audio player, container, and all UI custom elements. +import './ui'; function getTemplateHTML() { return /*html*/ ` @@ -35,6 +35,8 @@ function getTemplateHTML() { ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + diff --git a/packages/html/src/define/live-audio/ui.ts b/packages/html/src/define/live-audio/ui.ts new file mode 100644 index 000000000..6854ffcd9 --- /dev/null +++ b/packages/html/src/define/live-audio/ui.ts @@ -0,0 +1,38 @@ +// Registers the live audio player, container, and all audio UI custom +// elements without creating a skin element. Use this entry when building an +// ejected (light DOM) player layout for live HLS / DASH streams. +import { MediaContainerElement } from '../../media/container-element'; +import { GestureElement } from '../../ui/gesture/gesture-element'; +import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; +import { LiveButtonElement } from '../../ui/live-button/live-button-element'; +import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; +import { PlayButtonElement } from '../../ui/play-button/play-button-element'; +import { PopoverElement } from '../../ui/popover/popover-element'; +import { TooltipElement } from '../../ui/tooltip/tooltip-element'; +import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { safeDefine } from '../safe-define'; +import { defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; + +// Value import — player.ts body runs before this module's body. +import { LiveAudioPlayerElement } from './player'; + +// ── Registration (providers / parents first) ──────────────────────────── + +safeDefine(LiveAudioPlayerElement); +safeDefine(MediaContainerElement); + +// Compound groups. +defineErrorDialog(); +defineTimeSlider(); +defineVolumeSlider(); +defineTime(); + +// Standalone elements. +safeDefine(GestureElement); +safeDefine(HotkeyElement); +safeDefine(LiveButtonElement); +safeDefine(MuteButtonElement); +safeDefine(PlayButtonElement); +safeDefine(PopoverElement); +safeDefine(TooltipElement); +safeDefine(TooltipGroupElement); diff --git a/packages/html/src/define/live-video/minimal-skin.tailwind.ts b/packages/html/src/define/live-video/minimal-skin.tailwind.ts index 3634e38a2..96ffd98a1 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -19,8 +19,8 @@ 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'; +// Register the live video player, container, and minimal UI custom elements. +import './minimal-ui'; function getTemplateHTML() { return /*html*/ ` @@ -58,6 +58,8 @@ function getTemplateHTML() { ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index b2945b218..353f51d48 100644 --- a/packages/html/src/define/live-video/minimal-skin.ts +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -4,8 +4,8 @@ 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'; +// Register the live video player, container, and minimal UI custom elements. +import './minimal-ui'; function getTemplateHTML() { return /*html*/ ` @@ -43,6 +43,8 @@ function getTemplateHTML() { ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + diff --git a/packages/html/src/define/live-video/minimal-ui.ts b/packages/html/src/define/live-video/minimal-ui.ts new file mode 100644 index 000000000..479b01ecb --- /dev/null +++ b/packages/html/src/define/live-video/minimal-ui.ts @@ -0,0 +1,48 @@ +// Registers the live video player, container, and all video UI custom +// elements used by the minimal skin without creating a skin element. Use +// this entry when building an ejected (light DOM) player layout for live +// HLS / DASH streams. +import { MediaContainerElement } from '../../media/container-element'; +import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; +import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; +import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element'; +import { LiveButtonElement } from '../../ui/live-button/live-button-element'; +import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; +import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; +import { PlayButtonElement } from '../../ui/play-button/play-button-element'; +import { PopoverElement } from '../../ui/popover/popover-element'; +import { PosterElement } from '../../ui/poster/poster-element'; +import { TooltipElement } from '../../ui/tooltip/tooltip-element'; +import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { safeDefine } from '../safe-define'; +import { defineControls, defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; + +// Value import — player.ts body runs before this module's body. +import { LiveVideoPlayerElement } from './player'; + +// ── Registration (providers / parents first) ──────────────────────────── + +safeDefine(LiveVideoPlayerElement); +safeDefine(MediaContainerElement); + +// Compound groups. +defineControls(); +defineErrorDialog(); +defineTimeSlider(); +defineVolumeSlider(); +defineTime(); + +// Standalone elements. +safeDefine(BufferingIndicatorElement); +safeDefine(CaptionsButtonElement); +safeDefine(CastButtonElement); +safeDefine(FullscreenButtonElement); +safeDefine(LiveButtonElement); +safeDefine(MuteButtonElement); +safeDefine(PiPButtonElement); +safeDefine(PlayButtonElement); +safeDefine(PopoverElement); +safeDefine(PosterElement); +safeDefine(TooltipElement); +safeDefine(TooltipGroupElement); diff --git a/packages/html/src/define/live-video/player.ts b/packages/html/src/define/live-video/player.ts new file mode 100644 index 000000000..3b56bca1a --- /dev/null +++ b/packages/html/src/define/live-video/player.ts @@ -0,0 +1,23 @@ +import { liveVideoFeatures } from '@videojs/core/dom'; +import { MediaContainerElement } from '../../media/container-element'; +import { createPlayer } from '../../player/create-player'; +import { MediaElement } from '../../ui/media-element'; +import { safeDefine } from '../safe-define'; + +const { ProviderMixin } = createPlayer({ + features: liveVideoFeatures, +}); + +export class LiveVideoPlayerElement extends ProviderMixin(MediaElement) { + static readonly tagName = 'live-video-player'; +} + +// Provider must be defined before consumer for context handshake during upgrade. +safeDefine(LiveVideoPlayerElement); +safeDefine(MediaContainerElement); + +declare global { + interface HTMLElementTagNameMap { + [LiveVideoPlayerElement.tagName]: LiveVideoPlayerElement; + } +} diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index 32aec8082..e7ee88a15 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -19,8 +19,8 @@ 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'; +// Register the live video player, container, and all UI custom elements. +import './ui'; function getTemplateHTML() { return /*html*/ ` @@ -60,6 +60,8 @@ function getTemplateHTML() { ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index d051dedec..5fbc24300 100644 --- a/packages/html/src/define/live-video/skin.ts +++ b/packages/html/src/define/live-video/skin.ts @@ -4,9 +4,8 @@ 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'; +// Register the live video player, container, and all UI custom elements. +import './ui'; function getTemplateHTML() { return /*html*/ ` @@ -46,6 +45,8 @@ function getTemplateHTML() { ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + diff --git a/packages/html/src/define/live-video/ui.ts b/packages/html/src/define/live-video/ui.ts new file mode 100644 index 000000000..b25d2f4d7 --- /dev/null +++ b/packages/html/src/define/live-video/ui.ts @@ -0,0 +1,51 @@ +// Registers the live video player, container, and all video UI custom +// elements without creating a skin element. Use this entry when building an +// ejected (light DOM) player layout for live HLS / DASH streams. +import { MediaContainerElement } from '../../media/container-element'; +import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element'; +import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element'; +import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; +import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element'; +import { GestureElement } from '../../ui/gesture/gesture-element'; +import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; +import { LiveButtonElement } from '../../ui/live-button/live-button-element'; +import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; +import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; +import { PlayButtonElement } from '../../ui/play-button/play-button-element'; +import { PopoverElement } from '../../ui/popover/popover-element'; +import { PosterElement } from '../../ui/poster/poster-element'; +import { TooltipElement } from '../../ui/tooltip/tooltip-element'; +import { TooltipGroupElement } from '../../ui/tooltip/tooltip-group-element'; +import { safeDefine } from '../safe-define'; +import { defineControls, defineErrorDialog, defineTime, defineTimeSlider, defineVolumeSlider } from '../ui/compounds'; + +// Value import — player.ts body runs before this module's body. +import { LiveVideoPlayerElement } from './player'; + +// ── Registration (providers / parents first) ──────────────────────────── + +safeDefine(LiveVideoPlayerElement); +safeDefine(MediaContainerElement); + +// Compound groups. +defineControls(); +defineErrorDialog(); +defineTimeSlider(); +defineVolumeSlider(); +defineTime(); + +// Standalone elements. +safeDefine(BufferingIndicatorElement); +safeDefine(CaptionsButtonElement); +safeDefine(CastButtonElement); +safeDefine(FullscreenButtonElement); +safeDefine(GestureElement); +safeDefine(HotkeyElement); +safeDefine(LiveButtonElement); +safeDefine(MuteButtonElement); +safeDefine(PiPButtonElement); +safeDefine(PlayButtonElement); +safeDefine(PopoverElement); +safeDefine(PosterElement); +safeDefine(TooltipElement); +safeDefine(TooltipGroupElement); diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 2f19f9393..3eb3afa3d 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -8,6 +8,7 @@ import { CastButtonElement } from '../../ui/cast-button/cast-button-element'; import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-button-element'; import { GestureElement } from '../../ui/gesture/gesture-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; +import { LiveButtonElement } from '../../ui/live-button/live-button-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; @@ -42,6 +43,7 @@ safeDefine(CastButtonElement); safeDefine(FullscreenButtonElement); safeDefine(GestureElement); safeDefine(HotkeyElement); +safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 16885848f..261819b0b 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -41,6 +41,7 @@ export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-butto export { GestureElement } from './ui/gesture/gesture-element'; export { AriaKeyShortcutsController } from './ui/hotkey/aria-key-shortcuts-controller'; export { HotkeyElement } from './ui/hotkey/hotkey-element'; +export { LiveButtonElement } from './ui/live-button/live-button-element'; export { MediaButtonElement } from './ui/media-button-element'; // Primitives export * from './ui/media-element'; diff --git a/packages/html/src/presets/live-audio.ts b/packages/html/src/presets/live-audio.ts index cc4673a89..bde609896 100644 --- a/packages/html/src/presets/live-audio.ts +++ b/packages/html/src/presets/live-audio.ts @@ -1,6 +1,7 @@ -/** Live audio player preset — same features as `audio` with a skin that omits duration / current-time displays. */ +/** Live audio player preset — `liveAudioFeatures` (adds `liveFeature`, drops `playbackRateFeature`) with a skin that includes a Live button. */ export { liveAudioFeatures } from '@videojs/core/dom'; export { MinimalLiveAudioSkinElement } from '../define/live-audio/minimal-skin'; export { MinimalLiveAudioSkinTailwindElement } from '../define/live-audio/minimal-skin.tailwind'; +export { LiveAudioPlayerElement } from '../define/live-audio/player'; 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 index 2bd5fc460..63f61727b 100644 --- a/packages/html/src/presets/live-video.ts +++ b/packages/html/src/presets/live-video.ts @@ -1,6 +1,7 @@ -/** Live video player preset — same features as `video` with a skin that omits duration / current-time displays. */ +/** Live video player preset — `liveVideoFeatures` (adds `liveFeature`, drops `playbackRateFeature`) with a skin that includes a Live button. */ export { liveVideoFeatures } from '@videojs/core/dom'; export { MinimalLiveVideoSkinElement } from '../define/live-video/minimal-skin'; export { MinimalLiveVideoSkinTailwindElement } from '../define/live-video/minimal-skin.tailwind'; +export { LiveVideoPlayerElement } from '../define/live-video/player'; export { LiveVideoSkinElement } from '../define/live-video/skin'; export { LiveVideoSkinTailwindElement } from '../define/live-video/skin.tailwind'; diff --git a/packages/html/src/ui/live-button/live-button-element.ts b/packages/html/src/ui/live-button/live-button-element.ts new file mode 100644 index 000000000..6bf849b07 --- /dev/null +++ b/packages/html/src/ui/live-button/live-button-element.ts @@ -0,0 +1,120 @@ +import { LiveButtonCore, LiveButtonDataAttrs, type LiveButtonMediaState } from '@videojs/core'; +import { + applyElementProps, + applyStateDataAttrs, + createButton, + logMissingFeature, + selectBuffer, + selectLive, + selectTime, +} from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import type { State } from '@videojs/store'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaElement } from '../media-element'; + +/** + * `` — selects from `live`, `time`, and `buffer` features + * and composes them into the `LiveButtonMediaState` consumed by + * `LiveButtonCore`. + * + * Doesn't extend `MediaButtonElement` because that base couples a button to + * a single feature selector; the LiveButton needs three. + */ +export class LiveButtonElement extends MediaElement { + static readonly tagName = 'media-live-button'; + + static override properties: PropertyDeclarationMap = { + label: { type: String }, + disabled: { type: Boolean }, + }; + + disabled = false; + label = ''; + + protected readonly core = new LiveButtonCore(); + + protected readonly live = new PlayerController(this, playerContext, selectLive); + protected readonly time = new PlayerController(this, playerContext, selectTime); + protected readonly buffer = new PlayerController(this, playerContext, selectBuffer); + + get $state(): State { + return this.core.state; + } + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + if (this.destroyed) return; + + if (!this.textContent?.trim()) { + this.textContent = LiveButtonCore.defaultText; + } + + this.#disconnect = new AbortController(); + + const buttonProps = createButton({ + onActivate: () => { + const media = this.#getMedia(); + if (media) this.core.seekToLive(media); + }, + isDisabled: () => this.disabled || !this.#getMedia(), + }); + + applyElementProps(this, buttonProps, { signal: this.#disconnect.signal }); + + if (__DEV__ && !this.#getMedia()) { + logMissingFeature(this.localName, this.live.displayName ?? 'live'); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } + + /** Returns the button's current label derived from media state. */ + getLabel(): string | undefined { + return this.core.state.current.label || undefined; + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + this.core.setProps(this); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const media = this.#getMedia(); + if (!media) return; + + this.core.setMedia(media); + const state = this.core.getState(); + applyElementProps(this, this.core.getAttrs(state)); + applyStateDataAttrs(this, state, LiveButtonDataAttrs); + } + + /** + * Compose the LiveButton media state from the three feature slices. + * Returns `null` when any are missing so the button stays disabled until + * all three features are registered on the player. + */ + #getMedia(): LiveButtonMediaState | null { + const live = this.live.value; + const time = this.time.value; + const buffer = this.buffer.value; + if (!live || !time || !buffer) return null; + return { + currentTime: time.currentTime, + seek: time.seek, + seekable: buffer.seekable, + liveEdgeStart: live.liveEdgeStart, + targetLiveWindow: live.targetLiveWindow, + }; + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index fd7b7dd4f..a4f614255 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -47,6 +47,7 @@ export { useSlider } from './ui/hooks/use-slider'; export { Hotkey, type HotkeyProps, MediaHotkey, type MediaHotkeyProps } from './ui/hotkey/hotkey'; export { useAriaKeyShortcuts } from './ui/hotkey/use-aria-key-shortcuts'; export { type UseHotkeyOptions, useHotkey } from './ui/hotkey/use-hotkey'; +export { LiveButton, type LiveButtonProps } from './ui/live-button/live-button'; export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; diff --git a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx index 5a18cf8f7..884f4b8f7 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx @@ -14,6 +14,7 @@ import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { PauseIcon, PlayIcon, RestartIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; +import { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; @@ -128,6 +129,8 @@ export function MinimalLiveAudioSkinTailwind(props: MinimalLiveAudioSkinProps): /> + +