feat(packages): add UI support for gestures and hotkeys (#1388)

Co-authored-by: Rahim <rahim.alwer@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Potts
2026-05-05 10:34:12 +10:00
committed by GitHub
co-authored by Rahim Claude Opus 4.6
parent 6c81f2d190
commit 0620814a67
187 changed files with 5665 additions and 405 deletions
@@ -80,6 +80,7 @@ export class GestureElement extends MediaElement {
region: this.region,
disabled: this.disabled,
action: this.action,
value: this.value,
};
if (this.type === 'doubletap') {
@@ -65,6 +65,7 @@ export class HotkeyElement extends MediaElement {
this.#cleanup = createHotkey(container, {
keys: this.keys,
action,
value,
target: this.target,
disabled: this.disabled,
repeatable: !isHotkeyToggleAction(action),
@@ -0,0 +1,152 @@
import {
getRenderedIndicatorState,
type IndicatorLifecycleState,
type IndicatorVisibilityHandle,
type InputActionEvent,
isIndicatorPresent,
type MediaSnapshot,
} from '@videojs/core';
import {
getIndicatorVisibilityCoordinator,
getMediaSnapshot,
subscribeToInputActions,
type TransitionApi,
} from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import type { State as StoreState } from '@videojs/store';
import { containerContext, playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
import type { LiveIndicator } from './live-indicator';
/** Shared imperative API for status / volume / seek indicator cores. */
export interface InputIndicatorCoreApi<IndicatorState extends IndicatorLifecycleState> {
readonly state: StoreState<IndicatorState>;
destroy(): void;
close(): void;
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean;
}
export abstract class InputIndicatorElement<IndicatorState extends IndicatorLifecycleState> extends MediaElement {
protected abstract get core(): InputIndicatorCoreApi<IndicatorState>;
protected abstract get transition(): TransitionApi;
protected abstract get liveIndicator(): LiveIndicator<IndicatorState>;
protected abstract syncCoreProps(): void;
protected readonly player = new PlayerController(this, playerContext);
protected readonly container = new ContextConsumer(this, {
context: containerContext,
callback: () => this.#reconnect(),
subscribe: true,
});
#disconnect: AbortController | null = null;
#inputActionUnsubscribe: (() => void) | null = null;
#visibilityUnsubscribe: (() => void) | null = null;
#visibilityHandle: IndicatorVisibilityHandle | null = null;
#lastGeneration = 0;
#snapshot: IndicatorState | null = null;
#getVisibilityHandle(): IndicatorVisibilityHandle {
return (this.#visibilityHandle ??= { close: () => this.core.close() });
}
#payloadSnapshot(): IndicatorState {
return this.#snapshot ?? this.core.state.current;
}
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
this.#snapshot = this.core.state.current;
this.#disconnect = new AbortController();
this.core.state.subscribe(() => this.requestUpdate(), { signal: this.#disconnect.signal });
this.transition.state.subscribe(() => this.requestUpdate(), { signal: this.#disconnect.signal });
this.hidden = true;
this.#reconnect();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#inputActionUnsubscribe?.();
this.#visibilityUnsubscribe?.();
this.#inputActionUnsubscribe = null;
this.#visibilityUnsubscribe = null;
this.#disconnect?.abort();
this.#disconnect = null;
}
override destroyCallback(): void {
this.#inputActionUnsubscribe?.();
this.#visibilityUnsubscribe?.();
this.core.destroy();
this.transition.destroy();
this.liveIndicator.remove();
super.destroyCallback();
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.syncCoreProps();
}
protected override update(changed: PropertyValues): void {
super.update(changed);
this.#syncTransition();
const currentState = this.core.state.current;
const transitionState = this.transition.state.current;
const present = isIndicatorPresent(currentState, transitionState);
if (!present) {
this.liveIndicator.remove();
return;
}
const state = getRenderedIndicatorState(currentState, this.#payloadSnapshot(), transitionState);
this.liveIndicator.render(state);
}
#syncTransition(): void {
const currentState = this.core.state.current;
if (currentState.open) {
this.#snapshot = currentState;
if (this.#lastGeneration !== currentState.generation) {
this.#lastGeneration = currentState.generation;
void this.transition.open();
}
return;
}
const { active, status } = this.transition.state.current;
if (active && status !== 'ending') {
void this.transition.close(this.liveIndicator.element);
}
}
#reconnect(): void {
this.#inputActionUnsubscribe?.();
this.#visibilityUnsubscribe?.();
this.#inputActionUnsubscribe = null;
this.#visibilityUnsubscribe = null;
const container = this.container.value?.container;
if (!container) return;
const visibility = getIndicatorVisibilityCoordinator(container);
const visibilityHandle = this.#getVisibilityHandle();
this.#visibilityUnsubscribe = visibility.register(visibilityHandle);
this.#inputActionUnsubscribe = subscribeToInputActions(container, (event) => {
if (this.core.processEvent(event, getMediaSnapshot(this.player.value))) {
visibility.show(visibilityHandle);
}
});
}
}
@@ -0,0 +1,41 @@
import type { StateAttrMap } from '@videojs/core';
import { applyStateDataAttrs } from '@videojs/core/dom';
export interface LiveIndicatorOptions<State extends object> {
host: HTMLElement;
dataAttrs: StateAttrMap<State>;
render: (element: HTMLElement, state: State) => void;
}
export class LiveIndicator<State extends object> {
readonly #host: HTMLElement;
readonly #dataAttrs: StateAttrMap<State>;
readonly #render: (element: HTMLElement, state: State) => void;
constructor(options: LiveIndicatorOptions<State>) {
this.#host = options.host;
this.#dataAttrs = options.dataAttrs;
this.#render = options.render;
}
get element(): HTMLElement {
return this.#host;
}
render(state: State): HTMLElement {
this.#host.hidden = false;
applyStateDataAttrs(this.#host, state, this.#dataAttrs);
this.#render(this.#host, state);
return this.#host;
}
remove(): void {
this.#host.hidden = true;
for (const key in this.#dataAttrs) {
const name = this.#dataAttrs[key];
if (name) this.#host.removeAttribute(name);
}
}
}
@@ -0,0 +1,101 @@
import {
getVolumeIndicatorDisplayValue,
type VolumeIndicatorCore,
VolumeIndicatorCSSVars,
VolumeIndicatorDataAttrs,
} from '@videojs/core';
import { getIndicatorVisibilityCoordinator } from '@videojs/core/dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SeekIndicatorElement } from '../../seek-indicator/seek-indicator-element';
import { SeekIndicatorValueElement } from '../../seek-indicator/seek-indicator-value-element';
import { StatusAnnouncerElement } from '../../status-announcer/status-announcer-element';
import { StatusIndicatorElement } from '../../status-indicator/status-indicator-element';
import { StatusIndicatorValueElement } from '../../status-indicator/status-indicator-value-element';
import { VolumeIndicatorElement } from '../../volume-indicator/volume-indicator-element';
import { VolumeIndicatorFillElement } from '../../volume-indicator/volume-indicator-fill-element';
import { VolumeIndicatorValueElement } from '../../volume-indicator/volume-indicator-value-element';
import { LiveIndicator } from '../live-indicator';
afterEach(() => {
document.body.replaceChildren();
});
describe('input indicators', () => {
it('exposes standalone indicator tag names', () => {
expect(StatusIndicatorElement.tagName).toBe('media-status-indicator');
expect(StatusIndicatorValueElement.tagName).toBe('media-status-indicator-value');
expect(StatusAnnouncerElement.tagName).toBe('media-status-announcer');
expect(VolumeIndicatorElement.tagName).toBe('media-volume-indicator');
expect(VolumeIndicatorFillElement.tagName).toBe('media-volume-indicator-fill');
expect(VolumeIndicatorValueElement.tagName).toBe('media-volume-indicator-value');
expect(SeekIndicatorElement.tagName).toBe('media-seek-indicator');
expect(SeekIndicatorValueElement.tagName).toBe('media-seek-indicator-value');
});
it('uses authored HTML indicators as the mounted visual surface', () => {
const host = document.createElement('media-volume-indicator');
host.hidden = true;
host.innerHTML = `
<media-volume-indicator-fill>
<media-volume-indicator-value></media-volume-indicator-value>
</media-volume-indicator-fill>
`;
document.body.append(host);
const indicator = new LiveIndicator<VolumeIndicatorCore.State>({
host,
dataAttrs: VolumeIndicatorDataAttrs,
render: (element, state) => {
element
.querySelector<HTMLElement>('media-volume-indicator-fill')
?.style.setProperty(VolumeIndicatorCSSVars.fill, state.fill ?? '');
const value = element.querySelector('media-volume-indicator-value');
if (value) value.textContent = getVolumeIndicatorDisplayValue(state);
},
});
const liveElement = indicator.render({
open: true,
generation: 1,
level: 'high',
value: '60%',
fill: '60%',
min: false,
max: false,
transitionStarting: true,
transitionEnding: false,
});
expect(liveElement).toBe(host);
expect(host.hidden).toBe(false);
expect(document.body.querySelectorAll('media-volume-indicator')).toHaveLength(1);
expect(liveElement.getAttribute('data-level')).toBe('high');
expect(liveElement.querySelector('media-volume-indicator-value')?.textContent).toBe('60%');
expect(
liveElement
.querySelector<HTMLElement>('media-volume-indicator-fill')
?.style.getPropertyValue(VolumeIndicatorCSSVars.fill)
).toBe('60%');
indicator.remove();
expect(host.hidden).toBe(true);
expect(document.body.querySelectorAll('media-volume-indicator')).toHaveLength(1);
expect(host.hasAttribute('data-open')).toBe(false);
expect(host.hasAttribute('data-level')).toBe(false);
});
it('shares a visibility coordinator per container', () => {
const container = document.createElement('div');
const first = { close: vi.fn() };
const second = { close: vi.fn() };
const coordinator = getIndicatorVisibilityCoordinator(container);
coordinator.register(first);
coordinator.register(second);
coordinator.show(second);
expect(getIndicatorVisibilityCoordinator(container)).toBe(coordinator);
expect(first.close).toHaveBeenCalledOnce();
expect(second.close).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,47 @@
import { getSeekIndicatorDisplayValue, SeekIndicatorCore, SeekIndicatorDataAttrs } from '@videojs/core';
import { createTransition } from '@videojs/core/dom';
import type { PropertyDeclarationMap } from '@videojs/element';
import { InputIndicatorElement } from '../input-indicators/input-indicator-element';
import { LiveIndicator } from '../input-indicators/live-indicator';
export class SeekIndicatorElement extends InputIndicatorElement<SeekIndicatorCore.State> {
static readonly tagName = 'media-seek-indicator';
static override properties = {
closeDelay: { type: Number, attribute: 'close-delay' },
} satisfies PropertyDeclarationMap<'closeDelay'>;
closeDelay: number | undefined;
readonly #core = new SeekIndicatorCore();
readonly #transition = createTransition();
readonly #liveIndicator = new LiveIndicator({
host: this,
dataAttrs: SeekIndicatorDataAttrs,
render: renderSeekIndicator,
});
protected get core() {
return this.#core;
}
protected get transition() {
return this.#transition;
}
protected get liveIndicator() {
return this.#liveIndicator;
}
protected override syncCoreProps(): void {
this.#core.setProps({ closeDelay: this.closeDelay });
}
}
function renderSeekIndicator(element: HTMLElement, state: SeekIndicatorCore.State): void {
const value = element.querySelector('media-seek-indicator-value');
if (!value) return;
value.textContent = getSeekIndicatorDisplayValue(state);
}
@@ -0,0 +1,5 @@
import { MediaElement } from '../media-element';
export class SeekIndicatorValueElement extends MediaElement {
static readonly tagName = 'media-seek-indicator-value';
}
@@ -0,0 +1,82 @@
import { StatusAnnouncerCore } from '@videojs/core';
import { getMediaSnapshot, subscribeToInputActions } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { containerContext, playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class StatusAnnouncerElement extends MediaElement {
static readonly tagName = 'media-status-announcer';
static override properties = {
closeDelay: { type: Number, attribute: 'close-delay' },
} satisfies PropertyDeclarationMap<'closeDelay'>;
closeDelay: number | undefined;
readonly #core = new StatusAnnouncerCore();
readonly #player = new PlayerController(this, playerContext);
readonly #container = new ContextConsumer(this, {
context: containerContext,
callback: () => this.#reconnect(),
subscribe: true,
});
#disconnect: AbortController | null = null;
#inputActionUnsubscribe: (() => void) | null = null;
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
this.setAttribute('role', 'status');
this.#disconnect = new AbortController();
this.#core.state.subscribe(() => this.requestUpdate(), { signal: this.#disconnect.signal });
this.#reconnect();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#inputActionUnsubscribe?.();
this.#inputActionUnsubscribe = null;
this.#disconnect?.abort();
this.#disconnect = null;
}
override destroyCallback(): void {
this.#inputActionUnsubscribe?.();
this.#core.destroy();
super.destroyCallback();
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.#core.setProps({ closeDelay: this.closeDelay });
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const label = this.#core.state.current.label;
if (label) {
this.setAttribute('aria-label', label);
} else {
this.removeAttribute('aria-label');
}
}
#reconnect(): void {
this.#inputActionUnsubscribe?.();
this.#inputActionUnsubscribe = null;
const container = this.#container.value?.container;
if (!container) return;
this.#inputActionUnsubscribe = subscribeToInputActions(container, (event) => {
this.#core.processEvent(event, getMediaSnapshot(this.#player.value));
});
}
}
@@ -0,0 +1,58 @@
import {
getStatusIndicatorDisplayValue,
type InputAction,
StatusIndicatorCore,
StatusIndicatorDataAttrs,
} from '@videojs/core';
import { createTransition } from '@videojs/core/dom';
import type { PropertyDeclarationMap } from '@videojs/element';
import { InputIndicatorElement } from '../input-indicators/input-indicator-element';
import { LiveIndicator } from '../input-indicators/live-indicator';
export class StatusIndicatorElement extends InputIndicatorElement<StatusIndicatorCore.State> {
static readonly tagName = 'media-status-indicator';
static override properties = {
actions: { type: String },
closeDelay: { type: Number, attribute: 'close-delay' },
} satisfies PropertyDeclarationMap<'actions' | 'closeDelay'>;
actions: string | undefined;
closeDelay: number | undefined;
readonly #core = new StatusIndicatorCore();
readonly #transition = createTransition();
readonly #liveIndicator = new LiveIndicator({
host: this,
dataAttrs: StatusIndicatorDataAttrs,
render: renderStatusIndicator,
});
protected get core() {
return this.#core;
}
protected get transition() {
return this.#transition;
}
protected get liveIndicator() {
return this.#liveIndicator;
}
protected override syncCoreProps(): void {
this.#core.setProps({ actions: parseActions(this.actions), closeDelay: this.closeDelay });
}
}
function parseActions(actions: string | undefined): readonly InputAction[] | undefined {
return actions?.split(/[\s,]+/).filter(Boolean) as readonly InputAction[] | undefined;
}
function renderStatusIndicator(element: HTMLElement, state: StatusIndicatorCore.State): void {
const value = element.querySelector('media-status-indicator-value');
if (!value) return;
value.textContent = getStatusIndicatorDisplayValue(state);
}
@@ -0,0 +1,5 @@
import { MediaElement } from '../media-element';
export class StatusIndicatorValueElement extends MediaElement {
static readonly tagName = 'media-status-indicator-value';
}
@@ -0,0 +1,60 @@
import {
getVolumeIndicatorDisplayValue,
VolumeIndicatorCore,
VolumeIndicatorCSSVars,
VolumeIndicatorDataAttrs,
} from '@videojs/core';
import { createTransition } from '@videojs/core/dom';
import type { PropertyDeclarationMap } from '@videojs/element';
import { InputIndicatorElement } from '../input-indicators/input-indicator-element';
import { LiveIndicator } from '../input-indicators/live-indicator';
export class VolumeIndicatorElement extends InputIndicatorElement<VolumeIndicatorCore.State> {
static readonly tagName = 'media-volume-indicator';
static override properties = {
closeDelay: { type: Number, attribute: 'close-delay' },
} satisfies PropertyDeclarationMap<'closeDelay'>;
closeDelay: number | undefined;
readonly #core = new VolumeIndicatorCore();
readonly #transition = createTransition();
readonly #liveIndicator = new LiveIndicator({
host: this,
dataAttrs: VolumeIndicatorDataAttrs,
render: renderVolumeIndicator,
});
protected get core() {
return this.#core;
}
protected get transition() {
return this.#transition;
}
protected get liveIndicator() {
return this.#liveIndicator;
}
protected override syncCoreProps(): void {
this.#core.setProps({ closeDelay: this.closeDelay });
}
}
function renderVolumeIndicator(element: HTMLElement, state: VolumeIndicatorCore.State): void {
const fill = element.querySelector<HTMLElement>('media-volume-indicator-fill');
const value = element.querySelector('media-volume-indicator-value');
if (state.fill) {
fill?.style.setProperty(VolumeIndicatorCSSVars.fill, state.fill);
} else {
fill?.style.removeProperty(VolumeIndicatorCSSVars.fill);
}
if (value) {
value.textContent = getVolumeIndicatorDisplayValue(state);
}
}
@@ -0,0 +1,5 @@
import { MediaElement } from '../media-element';
export class VolumeIndicatorFillElement extends MediaElement {
static readonly tagName = 'media-volume-indicator-fill';
}
@@ -0,0 +1,5 @@
import { MediaElement } from '../media-element';
export class VolumeIndicatorValueElement extends MediaElement {
static readonly tagName = 'media-volume-indicator-value';
}