feat(packages): add live button component (#1473)

This commit is contained in:
Wesley Luyten
2026-04-30 18:00:15 -07:00
committed by GitHub
parent c5b06cf948
commit e37d5df873
70 changed files with 2207 additions and 118 deletions
+2
View File
@@ -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);
+7 -3
View File
@@ -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 {
z-index: 1;
font-family: inherit;
scale: 0.98;
@@ -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 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 +46,8 @@ function getTemplateHTML() {
${renderIcon('pause', { class: cn(icon, iconState.play.pause) })}
</media-play-button>
<media-tooltip id="play-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-live-button class="${cn(button.base, button.subtle, button.live)}"></media-live-button>
</div>
<div class="grow" aria-hidden="true"></div>
@@ -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' })}
</media-play-button>
<media-tooltip id="play-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-live-button class="media-button media-button--subtle media-button--live"></media-live-button>
</div>
<div class="media-time-controls" aria-hidden="true"></div>
@@ -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);
@@ -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;
}
}
@@ -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) })}
</media-play-button>
<media-tooltip id="play-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-live-button class="${cn(button.base, button.subtle, button.live)}"></media-live-button>
</div>
<div class="grow" aria-hidden="true"></div>
+4 -2
View File
@@ -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' })}
</media-play-button>
<media-tooltip id="play-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-live-button class="media-button media-button--subtle media-button--live"></media-live-button>
</div>
<div class="media-time-controls" aria-hidden="true"></div>
+38
View File
@@ -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);
@@ -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) })}
</media-play-button>
<media-tooltip id="play-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-live-button class="${cn(button.base, button.subtle, button.live)}"></media-live-button>
</div>
<div class="grow" aria-hidden="true"></div>
@@ -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' })}
</media-play-button>
<media-tooltip id="play-tooltip" side="top" class="media-tooltip"></media-tooltip>
<media-live-button class="media-button media-button--subtle media-button--live"></media-live-button>
</div>
<div class="media-time-controls" aria-hidden="true"></div>
@@ -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);
@@ -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;
}
}
@@ -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) })}
</media-play-button>
<media-tooltip id="play-tooltip" side="top" class="${cn(popup.tooltip)}"></media-tooltip>
<media-live-button class="${cn(button.base, button.subtle, button.live)}"></media-live-button>
</div>
<div class="grow" aria-hidden="true"></div>
+4 -3
View File
@@ -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' })}
</media-play-button>
<media-tooltip id="play-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>
<media-live-button class="media-button media-button--subtle media-button--live"></media-live-button>
</div>
<div class="media-time-controls" aria-hidden="true"></div>
+51
View File
@@ -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);
+2
View File
@@ -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);
+1
View File
@@ -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';
+2 -1
View File
@@ -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';
+2 -1
View File
@@ -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';
@@ -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';
/**
* `<media-live-button>` 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<LiveButtonCore.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,
};
}
}