From 42e612d95e61c41bb9588985213252ffabbad6b8 Mon Sep 17 00:00:00 2001 From: Wesley Luyten Date: Mon, 27 Apr 2026 17:47:26 -0700 Subject: [PATCH 01/10] feat(packages): add live button component Adds a Live button for live/DVR streams that indicates whether playback is at the live edge and seeks to the Seekable Live Edge when activated. Hidden via aria-hidden when the stream is not live. Follows the media-ui-extensions Live Edge proposal (#0007). - LiveButtonCore with at-edge detection (currentTime >= liveEdgeStart, with conservative fallback when liveEdgeStart is unavailable) and a tolerance window so autoplay reliably reports live - HTML media-live-button element and React LiveButton component - data-live, data-edge, data-hidden attrs for skin styling - Default and minimal skin chrome includes the live button next to PlayButton in the live-audio and live-video presets Refs #1390 Made-with: Cursor --- packages/core/src/core/index.ts | 2 + .../core/ui/live-button/live-button-core.ts | 150 ++++++++++ .../ui/live-button/live-button-data-attrs.ts | 9 + .../tests/live-button-core.test.ts | 269 ++++++++++++++++++ packages/core/src/dom/index.ts | 1 + packages/core/src/dom/media/hls/live.ts | 57 +++- .../core/src/dom/media/hls/tests/live.test.ts | 124 ++++++++ packages/core/src/dom/ui/live-button.ts | 31 ++ packages/html/src/define/audio/ui.ts | 2 + packages/html/src/define/base.css | 10 +- .../live-audio/minimal-skin.tailwind.ts | 10 +- .../src/define/live-audio/minimal-skin.ts | 9 +- .../html/src/define/live-audio/minimal-ui.ts | 35 +++ packages/html/src/define/live-audio/player.ts | 23 ++ .../src/define/live-audio/skin.tailwind.ts | 10 +- packages/html/src/define/live-audio/skin.ts | 9 +- packages/html/src/define/live-audio/ui.ts | 38 +++ .../live-video/minimal-skin.tailwind.ts | 10 +- .../src/define/live-video/minimal-skin.ts | 9 +- .../html/src/define/live-video/minimal-ui.ts | 48 ++++ packages/html/src/define/live-video/player.ts | 23 ++ .../src/define/live-video/skin.tailwind.ts | 10 +- packages/html/src/define/live-video/skin.ts | 10 +- packages/html/src/define/live-video/ui.ts | 51 ++++ packages/html/src/define/video/ui.ts | 2 + packages/html/src/index.ts | 1 + packages/html/src/presets/live-audio.ts | 3 +- packages/html/src/presets/live-video.ts | 3 +- .../src/ui/live-button/live-button-element.ts | 18 ++ packages/react/src/index.ts | 1 + .../live-audio/minimal-skin.tailwind.tsx | 7 + .../src/presets/live-audio/minimal-skin.tsx | 6 + .../src/presets/live-audio/skin.tailwind.tsx | 7 + .../react/src/presets/live-audio/skin.tsx | 6 + .../live-video/minimal-skin.tailwind.tsx | 7 + .../src/presets/live-video/minimal-skin.tsx | 6 + .../src/presets/live-video/skin.tailwind.tsx | 7 + .../react/src/presets/live-video/skin.tsx | 6 + packages/react/src/ui/live-button/index.ts | 1 + .../react/src/ui/live-button/live-button.tsx | 38 +++ packages/skins/src/default/css/audio.css | 1 + .../default/css/components/live-button.css | 37 +++ packages/skins/src/default/css/video.css | 1 + .../src/default/tailwind/audio.tailwind.ts | 1 + .../tailwind/components/live-button.ts | 18 ++ .../src/default/tailwind/video.tailwind.ts | 1 + packages/skins/src/minimal/css/audio.css | 1 + .../minimal/css/components/live-button.css | 37 +++ packages/skins/src/minimal/css/video.css | 1 + .../src/minimal/tailwind/audio.tailwind.ts | 1 + .../tailwind/components/live-button.ts | 18 ++ .../src/minimal/tailwind/video.tailwind.ts | 1 + 52 files changed, 1163 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/core/ui/live-button/live-button-core.ts create mode 100644 packages/core/src/core/ui/live-button/live-button-data-attrs.ts create mode 100644 packages/core/src/core/ui/live-button/tests/live-button-core.test.ts create mode 100644 packages/core/src/dom/ui/live-button.ts create mode 100644 packages/html/src/define/live-audio/minimal-ui.ts create mode 100644 packages/html/src/define/live-audio/player.ts create mode 100644 packages/html/src/define/live-audio/ui.ts create mode 100644 packages/html/src/define/live-video/minimal-ui.ts create mode 100644 packages/html/src/define/live-video/player.ts create mode 100644 packages/html/src/define/live-video/ui.ts create mode 100644 packages/html/src/ui/live-button/live-button-element.ts create mode 100644 packages/react/src/ui/live-button/index.ts create mode 100644 packages/react/src/ui/live-button/live-button.tsx create mode 100644 packages/skins/src/default/css/components/live-button.css create mode 100644 packages/skins/src/default/tailwind/components/live-button.ts create mode 100644 packages/skins/src/minimal/css/components/live-button.css create mode 100644 packages/skins/src/minimal/tailwind/components/live-button.ts diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 7243824ca..a7176c90d 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -14,6 +14,8 @@ export * from './ui/error-dialog/error-dialog-core'; export * from './ui/error-dialog/error-dialog-data-attrs'; export * from './ui/fullscreen-button/fullscreen-button-core'; export * from './ui/fullscreen-button/fullscreen-button-data-attrs'; +export * from './ui/live-button/live-button-core'; +export * from './ui/live-button/live-button-data-attrs'; export * from './ui/mute-button/mute-button-core'; export * from './ui/mute-button/mute-button-data-attrs'; export * from './ui/pip-button/pip-button-core'; diff --git a/packages/core/src/core/ui/live-button/live-button-core.ts b/packages/core/src/core/ui/live-button/live-button-core.ts new file mode 100644 index 000000000..f7e847380 --- /dev/null +++ b/packages/core/src/core/ui/live-button/live-button-core.ts @@ -0,0 +1,150 @@ +import { createState } from '@videojs/store'; +import { defaults } from '@videojs/utils/object'; +import { isFunction } from '@videojs/utils/predicate'; +import type { NonNullableObject } from '@videojs/utils/types'; + +import type { MediaBufferState, MediaLiveState, MediaTimeState } from '../../media/state'; +import type { ButtonState } from '../types'; + +export interface LiveButtonProps { + /** Custom label for the button. */ + label?: string | ((state: LiveButtonState) => string) | undefined; + /** Whether the button is disabled. */ + disabled?: boolean | undefined; + /** + * Fallback offset (in seconds) from the end of the seekable window used to + * decide "at live edge" when `liveEdgeStart` is unavailable. Default `10`. + */ + liveEdgeOffset?: number | undefined; + /** + * Grace window (in seconds) before `liveEdgeStart` that still counts as + * "at the live edge". Absorbs the small gap between the player's initial + * playback position (e.g. hls.js `liveSyncDuration`) and the manifest's + * `HOLD-BACK`, so autoplay reliably reports live. Default `5`. + */ + liveEdgeTolerance?: number | undefined; +} + +/** Media state slice consumed by `LiveButtonCore`. */ +export type LiveButtonMediaState = Pick & + Pick & + MediaLiveState; + +export interface LiveButtonState extends ButtonState { + /** Whether the stream is live (or DVR). */ + live: boolean; + /** Whether playback is at the live edge. */ + timeIsLive: boolean; +} + +/** + * Core state machine for a "Live" button. Indicates whether the player is + * playing at the live edge and seeks to the Seekable Live Edge when activated. + * + * @see https://github.com/video-dev/media-ui-extensions/blob/main/proposals/0007-live-edge.md + */ +export class LiveButtonCore { + static readonly defaultProps: NonNullableObject = { + label: '', + disabled: false, + liveEdgeOffset: 10, + liveEdgeTolerance: 5, + }; + + readonly state = createState({ + live: false, + timeIsLive: false, + label: '', + }); + + #props = { ...LiveButtonCore.defaultProps }; + #media: LiveButtonMediaState | null = null; + + constructor(props?: LiveButtonProps) { + if (props) this.setProps(props); + } + + setProps(props: LiveButtonProps): void { + this.#props = defaults(props, LiveButtonCore.defaultProps); + } + + getLabel(state: LiveButtonState): string { + const { label } = this.#props; + + if (isFunction(label)) { + const customLabel = label(state); + if (customLabel) return customLabel; + } else if (label) { + return label; + } + + if (state.timeIsLive) return 'Playing live'; + return 'Seek to live edge'; + } + + getAttrs(state: LiveButtonState) { + const inactive = this.#props.disabled || state.timeIsLive; + return { + 'aria-label': this.getLabel(state), + 'aria-disabled': inactive ? 'true' : undefined, + }; + } + + setMedia(media: LiveButtonMediaState): void { + this.#media = media; + } + + getState(): LiveButtonState { + const media = this.#media!; + const live = isLiveMedia(media); + const timeIsLive = live && this.#isAtLiveEdge(media); + + this.state.patch({ live, timeIsLive }); + this.state.patch({ label: this.getLabel(this.state.current) }); + + return this.state.current; + } + + /** Seek to the Seekable Live Edge. No-op when not live or already at edge. */ + async seekToLive(media: LiveButtonMediaState): Promise { + if (this.#props.disabled) return; + if (!isLiveMedia(media)) return; + if (this.#isAtLiveEdge(media)) return; + + const target = liveEdgeTarget(media); + if (target == null) return; + + await media.seek(target); + } + + #isAtLiveEdge(media: LiveButtonMediaState): boolean { + const { currentTime, liveEdgeStart } = media; + if (Number.isFinite(liveEdgeStart)) { + return currentTime >= liveEdgeStart - this.#props.liveEdgeTolerance; + } + + // Fallback: treat the trailing `liveEdgeOffset` window as the live edge. + const target = liveEdgeTarget(media); + if (target == null) return false; + return currentTime >= target - this.#props.liveEdgeOffset; + } +} + +export namespace LiveButtonCore { + export type Props = LiveButtonProps; + export type State = LiveButtonState; + export type MediaState = LiveButtonMediaState; +} + +function isLiveMedia(media: LiveButtonMediaState): boolean { + // `targetLiveWindow` is `0` for low-latency live, `Infinity` for DVR, and + // `NaN` for on-demand or unknown — finite-or-infinite means live. + return !Number.isNaN(media.targetLiveWindow); +} + +function liveEdgeTarget(media: LiveButtonMediaState): number | null { + const { seekable } = media; + if (seekable.length === 0) return null; + const end = seekable[seekable.length - 1]![1]; + return Number.isFinite(end) ? end : null; +} diff --git a/packages/core/src/core/ui/live-button/live-button-data-attrs.ts b/packages/core/src/core/ui/live-button/live-button-data-attrs.ts new file mode 100644 index 000000000..b0c48aa59 --- /dev/null +++ b/packages/core/src/core/ui/live-button/live-button-data-attrs.ts @@ -0,0 +1,9 @@ +import type { StateAttrMap } from '../types'; +import type { LiveButtonState } from './live-button-core'; + +export const LiveButtonDataAttrs = { + /** Present when the stream is live (or DVR). */ + live: 'data-live', + /** Present when playback is at the live edge. */ + timeIsLive: 'data-edge', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts b/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts new file mode 100644 index 000000000..7a859fcd3 --- /dev/null +++ b/packages/core/src/core/ui/live-button/tests/live-button-core.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { LiveButtonMediaState, LiveButtonState } from '../live-button-core'; +import { LiveButtonCore } from '../live-button-core'; + +function createMediaState(overrides: Partial = {}): LiveButtonMediaState { + return { + currentTime: 0, + seekable: [], + liveEdgeStart: Number.NaN, + targetLiveWindow: Number.NaN, + seek: vi.fn(async (time: number) => time), + ...overrides, + }; +} + +function createState(overrides: Partial = {}): LiveButtonState { + return { + live: false, + timeIsLive: false, + label: '', + ...overrides, + }; +} + +describe('LiveButtonCore', () => { + describe('setProps', () => { + it('uses default props', () => { + const core = new LiveButtonCore(); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBeUndefined(); + }); + + it('accepts constructor props', () => { + const core = new LiveButtonCore({ disabled: true }); + const attrs = core.getAttrs(createState()); + expect(attrs['aria-disabled']).toBe('true'); + }); + }); + + describe('getState', () => { + it('reports not-live when stream is on-demand', () => { + const core = new LiveButtonCore(); + core.setMedia(createMediaState({ targetLiveWindow: Number.NaN })); + const state = core.getState(); + expect(state.live).toBe(false); + expect(state.timeIsLive).toBe(false); + }); + + it('reports live for low-latency live (`targetLiveWindow === 0`)', () => { + const core = new LiveButtonCore(); + core.setMedia(createMediaState({ targetLiveWindow: 0, seekable: [[0, 100]], liveEdgeStart: 95 })); + const state = core.getState(); + expect(state.live).toBe(true); + }); + + it('reports live for DVR (`targetLiveWindow === Infinity`)', () => { + const core = new LiveButtonCore(); + core.setMedia( + createMediaState({ + targetLiveWindow: Number.POSITIVE_INFINITY, + seekable: [[0, 1000]], + liveEdgeStart: 990, + }) + ); + expect(core.getState().live).toBe(true); + }); + + it('flags timeIsLive when currentTime >= liveEdgeStart', () => { + const core = new LiveButtonCore(); + core.setMedia( + createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: 95, + currentTime: 96, + }) + ); + expect(core.getState().timeIsLive).toBe(true); + }); + + it('clears timeIsLive when behind live', () => { + const core = new LiveButtonCore(); + core.setMedia( + createMediaState({ + targetLiveWindow: Number.POSITIVE_INFINITY, + seekable: [[0, 100]], + liveEdgeStart: 95, + currentTime: 50, + }) + ); + expect(core.getState().timeIsLive).toBe(false); + }); + + it('falls back to seekable end when liveEdgeStart is unknown', () => { + const core = new LiveButtonCore(); + core.setMedia( + createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: Number.NaN, + currentTime: 95, + }) + ); + expect(core.getState().timeIsLive).toBe(true); + }); + + it('respects custom liveEdgeOffset fallback', () => { + const core = new LiveButtonCore({ liveEdgeOffset: 2 }); + core.setMedia( + createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: Number.NaN, + currentTime: 95, + }) + ); + expect(core.getState().timeIsLive).toBe(false); + }); + + it('flags timeIsLive within the default 5s tolerance before liveEdgeStart', () => { + const core = new LiveButtonCore(); + core.setMedia( + createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: 95, + currentTime: 91, + }) + ); + expect(core.getState().timeIsLive).toBe(true); + }); + + it('clears timeIsLive when beyond the tolerance before liveEdgeStart', () => { + const core = new LiveButtonCore(); + core.setMedia( + createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: 95, + currentTime: 89, + }) + ); + expect(core.getState().timeIsLive).toBe(false); + }); + + it('respects custom liveEdgeTolerance', () => { + const core = new LiveButtonCore({ liveEdgeTolerance: 0 }); + core.setMedia( + createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: 95, + currentTime: 94, + }) + ); + expect(core.getState().timeIsLive).toBe(false); + }); + }); + + describe('getLabel', () => { + it('returns "Seek to live edge" when behind live', () => { + const core = new LiveButtonCore(); + expect(core.getLabel(createState({ live: true, timeIsLive: false }))).toBe('Seek to live edge'); + }); + + it('returns "Playing live" when at live edge', () => { + const core = new LiveButtonCore(); + expect(core.getLabel(createState({ live: true, timeIsLive: true }))).toBe('Playing live'); + }); + + it('returns custom string label', () => { + const core = new LiveButtonCore({ label: 'LIVE' }); + expect(core.getLabel(createState())).toBe('LIVE'); + }); + + it('returns custom function label', () => { + const core = new LiveButtonCore({ label: (state) => (state.timeIsLive ? 'LIVE' : 'GO LIVE') }); + expect(core.getLabel(createState({ timeIsLive: true }))).toBe('LIVE'); + expect(core.getLabel(createState({ timeIsLive: false }))).toBe('GO LIVE'); + }); + }); + + describe('getAttrs', () => { + it('sets aria-disabled when at live edge', () => { + const core = new LiveButtonCore(); + const attrs = core.getAttrs(createState({ timeIsLive: true, live: true })); + expect(attrs['aria-disabled']).toBe('true'); + }); + + it('sets aria-disabled when disabled', () => { + const core = new LiveButtonCore({ disabled: true }); + const attrs = core.getAttrs(createState({ live: true })); + expect(attrs['aria-disabled']).toBe('true'); + }); + }); + + describe('seekToLive', () => { + it('seeks to the end of the latest seekable range', async () => { + const core = new LiveButtonCore(); + const media = createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: 95, + currentTime: 50, + }); + await core.seekToLive(media); + expect(media.seek).toHaveBeenCalledWith(100); + }); + + it('uses the last seekable range when multiple are present', async () => { + const core = new LiveButtonCore(); + const media = createMediaState({ + targetLiveWindow: 0, + seekable: [ + [0, 50], + [60, 200], + ], + liveEdgeStart: 195, + currentTime: 70, + }); + await core.seekToLive(media); + expect(media.seek).toHaveBeenCalledWith(200); + }); + + it('does nothing when stream is not live', async () => { + const core = new LiveButtonCore(); + const media = createMediaState({ targetLiveWindow: Number.NaN }); + await core.seekToLive(media); + expect(media.seek).not.toHaveBeenCalled(); + }); + + it('does nothing when already at live edge', async () => { + const core = new LiveButtonCore(); + const media = createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: 95, + currentTime: 99, + }); + await core.seekToLive(media); + expect(media.seek).not.toHaveBeenCalled(); + }); + + it('does nothing when disabled', async () => { + const core = new LiveButtonCore({ disabled: true }); + const media = createMediaState({ + targetLiveWindow: 0, + seekable: [[0, 100]], + liveEdgeStart: 95, + currentTime: 50, + }); + await core.seekToLive(media); + expect(media.seek).not.toHaveBeenCalled(); + }); + + it('does nothing when seekable is empty', async () => { + const core = new LiveButtonCore(); + const media = createMediaState({ + targetLiveWindow: 0, + seekable: [], + liveEdgeStart: Number.NaN, + currentTime: 0, + }); + await core.seekToLive(media); + expect(media.seek).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/dom/index.ts b/packages/core/src/dom/index.ts index 828c6b15a..5fb632dba 100644 --- a/packages/core/src/dom/index.ts +++ b/packages/core/src/dom/index.ts @@ -14,6 +14,7 @@ export * from './ui/alert-dialog'; export * from './ui/button'; export * from './ui/dismiss-layer'; export * from './ui/event'; +export * from './ui/live-button'; export * from './ui/popover/popover'; export * from './ui/popover/popover-positioning'; export * from './ui/slider'; diff --git a/packages/core/src/dom/media/hls/live.ts b/packages/core/src/dom/media/hls/live.ts index e315de257..4370964ab 100644 --- a/packages/core/src/dom/media/hls/live.ts +++ b/packages/core/src/dom/media/hls/live.ts @@ -7,15 +7,28 @@ export function HlsJsMediaLiveMixin>(Bas class HlsJsMediaLive extends (BaseClass as Constructor) { #targetLiveWindow = Number.NaN; #liveEdgeStartOffset: number | undefined; + #seekToLiveAbort: AbortController | null = null; + #seekToLivePending = false; constructor(...args: any[]) { super(...args); const { engine } = this; - engine?.on(Hls.Events.MANIFEST_LOADING, () => this.#reset()); - engine?.on(Hls.Events.DESTROYING, () => this.#reset()); + engine?.on(Hls.Events.MANIFEST_LOADING, () => { + this.#reset(); + this.#armSeekToLive(); + }); + engine?.on(Hls.Events.MEDIA_ATTACHED, () => this.#armSeekToLive()); + engine?.on(Hls.Events.MEDIA_DETACHED, () => this.#disarmSeekToLive()); + engine?.on(Hls.Events.DESTROYING, () => { + this.#reset(); + this.#disarmSeekToLive(); + }); engine?.on(Hls.Events.LEVEL_LOADED, (_event: string, data: LevelLoadedData) => { this.#derive(data.details); + // For `preload="none"`/`"metadata"` the manifest only loads after the + // first play, so retry the seek once `liveEdgeStart` becomes finite. + if (this.#seekToLivePending) this.#trySeekToLive(); }); } @@ -61,6 +74,46 @@ export function HlsJsMediaLiveMixin>(Bas this.#targetLiveWindow = value; this.dispatchEvent(new Event('targetlivewindowchange')); } + + /** + * Arm a one-shot seek-to-live on the first user-initiated `play`. Skipped + * when `autoplay` is set, since hls.js positions at the live edge during + * its own startup sequence and a programmatic seek would race that. + */ + #armSeekToLive() { + this.#disarmSeekToLive(); + + const target = this.target as HTMLMediaElement | null; + if (!target || target.autoplay) return; + + this.#seekToLiveAbort = new AbortController(); + target.addEventListener( + 'play', + () => { + this.#seekToLivePending = true; + this.#trySeekToLive(); + }, + { signal: this.#seekToLiveAbort.signal, once: true } + ); + } + + #disarmSeekToLive() { + this.#seekToLiveAbort?.abort(); + this.#seekToLiveAbort = null; + this.#seekToLivePending = false; + } + + #trySeekToLive() { + const target = this.target as HTMLMediaElement | null; + if (!target) return; + const { liveEdgeStart } = this; + if (!Number.isFinite(liveEdgeStart)) return; + + if (target.currentTime < liveEdgeStart) { + target.currentTime = liveEdgeStart; + } + this.#seekToLivePending = false; + } } return HlsJsMediaLive as unknown as Base & diff --git a/packages/core/src/dom/media/hls/tests/live.test.ts b/packages/core/src/dom/media/hls/tests/live.test.ts index 69488ca0c..edf71b6b9 100644 --- a/packages/core/src/dom/media/hls/tests/live.test.ts +++ b/packages/core/src/dom/media/hls/tests/live.test.ts @@ -212,6 +212,130 @@ describe('HlsJsMediaLiveMixin', () => { }); }); + describe('seek-to-live on first play', () => { + function emitManifestLoading(engine: Hls) { + (engine as any).emit(Hls.Events.MANIFEST_LOADING); + } + + it('seeks to `liveEdgeStart` on the first `play` event', () => { + const engine = createEngine(); + const host = new HlsJsMediaLive(engine); + const video = setTargetSeekable(host, [[0, 60]]); + + emitManifestLoading(engine); + emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18, targetduration: 6 })); + + video.dispatchEvent(new Event('play')); + + expect(video.currentTime).toBe(42); + }); + + it('does not seek when `autoplay` is set', () => { + const engine = createEngine(); + const host = new HlsJsMediaLive(engine); + const video = setTargetSeekable(host, [[0, 60]]); + video.autoplay = true; + + emitManifestLoading(engine); + emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 })); + + video.dispatchEvent(new Event('play')); + + expect(video.currentTime).toBe(0); + }); + + it('only seeks on the first play (subsequent plays are ignored)', () => { + const engine = createEngine(); + const host = new HlsJsMediaLive(engine); + const video = setTargetSeekable(host, [[0, 60]]); + + emitManifestLoading(engine); + emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 })); + + video.dispatchEvent(new Event('play')); + expect(video.currentTime).toBe(42); + + video.currentTime = 30; + video.dispatchEvent(new Event('play')); + expect(video.currentTime).toBe(30); + }); + + it('does not seek backwards when already at or past the live edge', () => { + const engine = createEngine(); + const host = new HlsJsMediaLive(engine); + const video = setTargetSeekable(host, [[0, 60]]); + + emitManifestLoading(engine); + emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 })); + + video.currentTime = 50; + video.dispatchEvent(new Event('play')); + + expect(video.currentTime).toBe(50); + }); + + it('does not seek when stream is on-demand', () => { + const engine = createEngine(); + const host = new HlsJsMediaLive(engine); + const video = setTargetSeekable(host, [[0, 60]]); + + emitManifestLoading(engine); + emitLevelLoaded(engine, levelDetails({ live: false, type: 'VOD' })); + + video.dispatchEvent(new Event('play')); + + expect(video.currentTime).toBe(0); + }); + + it('defers the seek until `liveEdgeStart` becomes finite (preload="none")', () => { + const engine = createEngine(); + const host = new HlsJsMediaLive(engine); + const video = setTargetSeekable(host, [[0, 60]]); + + // Manifest is requested at startup but `LEVEL_LOADED` only arrives after `play`. + emitManifestLoading(engine); + video.dispatchEvent(new Event('play')); + expect(video.currentTime).toBe(0); + + emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 })); + + expect(video.currentTime).toBe(42); + }); + + it('re-arms on a subsequent source load', () => { + const engine = createEngine(); + const host = new HlsJsMediaLive(engine); + const video = setTargetSeekable(host, [[0, 60]]); + + emitManifestLoading(engine); + emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 })); + video.dispatchEvent(new Event('play')); + expect(video.currentTime).toBe(42); + + video.currentTime = 0; + emitManifestLoading(engine); + emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 })); + video.dispatchEvent(new Event('play')); + + expect(video.currentTime).toBe(42); + }); + + it('disarms on `DESTROYING`', () => { + const engine = createEngine(); + const host = new HlsJsMediaLive(engine); + const video = setTargetSeekable(host, [[0, 60]]); + + emitManifestLoading(engine); + emitLevelLoaded(engine, levelDetails({ live: true, holdBack: 18 })); + + (engine as any).emit(Hls.Events.DESTROYING); + + video.dispatchEvent(new Event('play')); + + expect(video.currentTime).toBe(0); + }); + }); + describe('reset', () => { it('resets on `MANIFEST_LOADING`', () => { const engine = createEngine(); diff --git a/packages/core/src/dom/ui/live-button.ts b/packages/core/src/dom/ui/live-button.ts new file mode 100644 index 000000000..bd5ff84f7 --- /dev/null +++ b/packages/core/src/dom/ui/live-button.ts @@ -0,0 +1,31 @@ +import type { Selector } from '@videojs/store'; + +import type { LiveButtonMediaState } from '../../core/ui/live-button/live-button-core'; +import { liveFeature } from '../store/features/live'; +import { selectBuffer, selectLive, selectTime } from '../store/selectors'; + +export type { LiveButtonMediaState }; + +/** + * Composite selector for the LiveButton — combines `live`, `time`, and + * `buffer` feature state so the button can both detect the live edge and + * seek to the Seekable Live Edge when activated. + * + * Returns `undefined` when any of the underlying features is missing. + */ +export const selectLiveButton: Selector = Object.assign( + (state: object): LiveButtonMediaState | undefined => { + const live = selectLive(state); + const time = selectTime(state); + const buffer = selectBuffer(state); + if (!live || !time || !buffer) return undefined; + return { + currentTime: time.currentTime, + seek: time.seek, + seekable: buffer.seekable, + liveEdgeStart: live.liveEdgeStart, + targetLiveWindow: live.targetLiveWindow, + }; + }, + { displayName: liveFeature.name } +); diff --git a/packages/html/src/define/audio/ui.ts b/packages/html/src/define/audio/ui.ts index 9b1cd07dd..d908036cc 100644 --- a/packages/html/src/define/audio/ui.ts +++ b/packages/html/src/define/audio/ui.ts @@ -4,6 +4,7 @@ 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 { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; @@ -31,6 +32,7 @@ defineTime(); // Standalone elements. safeDefine(GestureElement); safeDefine(HotkeyElement); +safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); safeDefine(PlayButtonElement); safeDefine(PlaybackRateButtonElement); diff --git a/packages/html/src/define/base.css b/packages/html/src/define/base.css index 2bafe8725..f09901042 100644 --- a/packages/html/src/define/base.css +++ b/packages/html/src/define/base.css @@ -2,7 +2,8 @@ /* Base */ /* -------------------------------------------------------------------------- */ -video-player { +video-player, +live-video-player { display: contents; } @@ -11,13 +12,16 @@ Required to override any default video and image styles (such as Tailwind's CSS reset) and ensure they fill the container as expected. */ video-player video, -video-player [slot="poster"] { +video-player [slot="poster"], +live-video-player video, +live-video-player [slot="poster"] { display: block; width: 100%; height: 100%; } -video-player video::-webkit-media-text-track-container { +video-player video::-webkit-media-text-track-container, +live-video-player video::-webkit-media-text-track-container { transition: translate var(--media-caption-track-duration, 0) ease-out; transition-delay: var(--media-caption-track-delay, 0); translate: 0 var(--media-caption-track-y, 0); diff --git a/packages/html/src/define/live-audio/minimal-skin.tailwind.ts b/packages/html/src/define/live-audio/minimal-skin.tailwind.ts index f52b59e95..4a7a50288 100644 --- a/packages/html/src/define/live-audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-audio/minimal-skin.tailwind.ts @@ -6,6 +6,7 @@ import { error, icon, iconState, + liveButton, popup, root, slider, @@ -15,8 +16,8 @@ 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'; +// Register the live audio player, container, and minimal UI custom elements. +import './minimal-ui'; function getTemplateHTML() { return /*html*/ ` @@ -46,6 +47,11 @@ function getTemplateHTML() { ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + + + LIVE + diff --git a/packages/html/src/define/live-audio/minimal-skin.ts b/packages/html/src/define/live-audio/minimal-skin.ts index 63f067888..762099b53 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,11 @@ function getTemplateHTML() { ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + LIVE + 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..9f997caef 100644 --- a/packages/html/src/define/live-audio/skin.tailwind.ts +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -6,6 +6,7 @@ import { error, icon, iconState, + liveButton, popup, root, slider, @@ -15,8 +16,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 +47,11 @@ function getTemplateHTML() { ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + + + LIVE + diff --git a/packages/html/src/define/live-audio/skin.ts b/packages/html/src/define/live-audio/skin.ts index ee0cee7aa..f94fc7b17 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,11 @@ function getTemplateHTML() { ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + LIVE + 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..1731dcd2a 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -8,6 +8,7 @@ import { error, icon, iconState, + liveButton, overlay, popup, poster, @@ -19,8 +20,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 +59,11 @@ function getTemplateHTML() { ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + + + LIVE + diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index 35e9e5f90..5fcb211df 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,11 @@ function getTemplateHTML() { ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + LIVE + 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..db05fdf04 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -8,6 +8,7 @@ import { error, icon, iconState, + liveButton, overlay, popup, poster, @@ -19,8 +20,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 +61,11 @@ function getTemplateHTML() { ${renderIcon('pause', { class: cn(icon, iconState.play.pause) })} + + + + LIVE + diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index d051dedec..39e49e542 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,11 @@ function getTemplateHTML() { ${renderIcon('pause', { class: 'media-icon media-icon--pause' })} + + + + LIVE + 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..26590ff8c --- /dev/null +++ b/packages/html/src/ui/live-button/live-button-element.ts @@ -0,0 +1,18 @@ +import { LiveButtonCore, LiveButtonDataAttrs } from '@videojs/core'; +import { type LiveButtonMediaState, selectLiveButton } from '@videojs/core/dom'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaButtonElement } from '../media-button-element'; + +export class LiveButtonElement extends MediaButtonElement { + static readonly tagName = 'media-live-button'; + + protected readonly core = new LiveButtonCore(); + protected readonly stateAttrMap = LiveButtonDataAttrs; + protected readonly mediaState = new PlayerController(this, playerContext, selectLiveButton); + + protected activate(state: LiveButtonMediaState): void { + this.core.seekToLive(state); + } +} 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 ad65a1dfa..b650e36cd 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx @@ -13,6 +13,7 @@ import { error, icon, iconState, + liveButton, popup, root, slider, @@ -21,6 +22,7 @@ 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 { LiveButton } from '@/ui/live-button'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; @@ -135,6 +137,11 @@ export function MinimalLiveAudioSkinTailwind(props: MinimalLiveAudioSkinProps): /> + + + ); 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 762099b53..7c616dd4c 100644 --- a/packages/html/src/define/live-audio/minimal-skin.ts +++ b/packages/html/src/define/live-audio/minimal-skin.ts @@ -36,10 +36,7 @@ function getTemplateHTML() { - - - LIVE - +
diff --git a/packages/html/src/define/live-audio/skin.tailwind.ts b/packages/html/src/define/live-audio/skin.tailwind.ts index 9f997caef..81ddc2d0a 100644 --- a/packages/html/src/define/live-audio/skin.tailwind.ts +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -48,10 +48,7 @@ function getTemplateHTML() { - - - LIVE - + diff --git a/packages/html/src/define/live-audio/skin.ts b/packages/html/src/define/live-audio/skin.ts index f94fc7b17..9a7d51c5c 100644 --- a/packages/html/src/define/live-audio/skin.ts +++ b/packages/html/src/define/live-audio/skin.ts @@ -36,10 +36,7 @@ function getTemplateHTML() { - - - LIVE - + 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 1731dcd2a..7781a0ff1 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -60,10 +60,7 @@ function getTemplateHTML() { - - - LIVE - + diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index 5fcb211df..a2dcaf9b7 100644 --- a/packages/html/src/define/live-video/minimal-skin.ts +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -44,10 +44,7 @@ function getTemplateHTML() { - - - LIVE - + diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index db05fdf04..7eeedd80f 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -62,10 +62,7 @@ function getTemplateHTML() { - - - LIVE - + diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index 39e49e542..5fbc24300 100644 --- a/packages/html/src/define/live-video/skin.ts +++ b/packages/html/src/define/live-video/skin.ts @@ -46,10 +46,7 @@ function getTemplateHTML() { - - - LIVE - + diff --git a/packages/html/src/ui/live-button/live-button-element.ts b/packages/html/src/ui/live-button/live-button-element.ts index 26590ff8c..d53ee32e7 100644 --- a/packages/html/src/ui/live-button/live-button-element.ts +++ b/packages/html/src/ui/live-button/live-button-element.ts @@ -12,6 +12,13 @@ export class LiveButtonElement extends MediaButtonElement { protected readonly stateAttrMap = LiveButtonDataAttrs; protected readonly mediaState = new PlayerController(this, playerContext, selectLiveButton); + override connectedCallback(): void { + super.connectedCallback(); + if (!this.textContent?.trim()) { + this.textContent = LiveButtonCore.defaultText; + } + } + protected activate(state: LiveButtonMediaState): void { this.core.seekToLive(state); } 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 b650e36cd..5fa298ff2 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx @@ -138,10 +138,7 @@ export function MinimalLiveAudioSkinTailwind(props: MinimalLiveAudioSkinProps): - - + diff --git a/packages/html/src/define/live-audio/skin.tailwind.ts b/packages/html/src/define/live-audio/skin.tailwind.ts index 81ddc2d0a..47eba5776 100644 --- a/packages/html/src/define/live-audio/skin.tailwind.ts +++ b/packages/html/src/define/live-audio/skin.tailwind.ts @@ -6,7 +6,6 @@ import { error, icon, iconState, - liveButton, popup, root, slider, @@ -48,7 +47,7 @@ function getTemplateHTML() { - + 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 7781a0ff1..96ffd98a1 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -8,7 +8,6 @@ import { error, icon, iconState, - liveButton, overlay, popup, poster, @@ -60,7 +59,7 @@ function getTemplateHTML() { - + diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index 7eeedd80f..e7ee88a15 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -8,7 +8,6 @@ import { error, icon, iconState, - liveButton, overlay, popup, poster, @@ -62,7 +61,7 @@ function getTemplateHTML() { - + 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 5fa298ff2..45d5a746b 100644 --- a/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-audio/minimal-skin.tailwind.tsx @@ -13,7 +13,6 @@ import { error, icon, iconState, - liveButton, popup, root, slider, @@ -138,7 +137,7 @@ export function MinimalLiveAudioSkinTailwind(props: MinimalLiveAudioSkinProps): - +