mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): add hotkeys (#1239)
This commit is contained in:
@@ -11,6 +11,7 @@ export class CaptionsButtonElement extends MediaButtonElement<CaptionsButtonCore
|
||||
protected readonly core = new CaptionsButtonCore();
|
||||
protected readonly stateAttrMap = CaptionsButtonDataAttrs;
|
||||
protected readonly mediaState = new PlayerController(this, playerContext, selectTextTrack);
|
||||
protected override readonly hotkeyAction = 'toggleSubtitles';
|
||||
|
||||
protected activate(state: MediaTextTrackState): void {
|
||||
this.core.toggle(state);
|
||||
|
||||
@@ -11,6 +11,7 @@ export class FullscreenButtonElement extends MediaButtonElement<FullscreenButton
|
||||
protected readonly core = new FullscreenButtonCore();
|
||||
protected readonly stateAttrMap = FullscreenButtonDataAttrs;
|
||||
protected readonly mediaState = new PlayerController(this, playerContext, selectFullscreen);
|
||||
protected override readonly hotkeyAction = 'toggleFullscreen';
|
||||
|
||||
protected activate(state: MediaFullscreenState): void {
|
||||
this.core.toggle(state);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createHotkey, type HotkeyActionName, isHotkeyToggleAction, resolveHotkeyAction } 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 HotkeyElement extends MediaElement {
|
||||
static readonly tagName = 'media-hotkey';
|
||||
|
||||
static override properties: PropertyDeclarationMap = {
|
||||
keys: { type: String },
|
||||
action: { type: String },
|
||||
value: { type: Number },
|
||||
disabled: { type: Boolean },
|
||||
target: { type: String },
|
||||
};
|
||||
|
||||
keys = '';
|
||||
action: HotkeyActionName | (string & {}) = '';
|
||||
value: number | undefined = undefined;
|
||||
disabled = false;
|
||||
target: 'player' | 'document' = 'player';
|
||||
|
||||
readonly #player = new PlayerController(this, playerContext);
|
||||
readonly #container = new ContextConsumer(this, { context: containerContext, subscribe: true });
|
||||
#cleanup: (() => void) | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = 'none';
|
||||
this.#register();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#unregister();
|
||||
}
|
||||
|
||||
protected override update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
|
||||
// Re-register when attributes change.
|
||||
if (this.isConnected) {
|
||||
this.#unregister();
|
||||
this.#register();
|
||||
}
|
||||
}
|
||||
|
||||
#register(): void {
|
||||
const store = this.#player.value;
|
||||
const container = this.#container.value?.container;
|
||||
if (!this.keys || !this.action || !store || !container) return;
|
||||
|
||||
const resolver = resolveHotkeyAction(this.action);
|
||||
if (!resolver) return;
|
||||
|
||||
const { value, action } = this;
|
||||
|
||||
this.#cleanup = createHotkey(container as HTMLElement, {
|
||||
keys: this.keys,
|
||||
action,
|
||||
target: this.target,
|
||||
disabled: this.disabled,
|
||||
allowRepeat: !isHotkeyToggleAction(action),
|
||||
onActivate: (_event, key) => {
|
||||
resolver({ store, key, ...(value !== undefined && { value }) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#unregister(): void {
|
||||
this.#cleanup?.();
|
||||
this.#cleanup = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { findHotkeyCoordinator } from '@videojs/core/dom';
|
||||
import type { ReactiveController } from '@videojs/element';
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
|
||||
import type { ContainerContextConsumer } from '../../player/context';
|
||||
import { containerContext } from '../../player/context';
|
||||
import type { PlayerControllerHost } from '../../player/player-controller';
|
||||
|
||||
/** Provides `aria-keyshortcuts` for a given hotkey action name. */
|
||||
export class HotkeyRegistryController implements ReactiveController {
|
||||
#action: string;
|
||||
#container: ContainerContextConsumer;
|
||||
|
||||
constructor(host: PlayerControllerHost, action: string) {
|
||||
this.#action = action;
|
||||
this.#container = new ContextConsumer(host, { context: containerContext, subscribe: true });
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get value(): string | undefined {
|
||||
const container = this.#container.value?.container;
|
||||
if (!container) return undefined;
|
||||
return findHotkeyCoordinator(container as HTMLElement)?.getAriaKeys(this.#action);
|
||||
}
|
||||
|
||||
hostConnected(): void {}
|
||||
hostDisconnected(): void {}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { HotkeyElement } from '../hotkey-element';
|
||||
import { HotkeyRegistryController } from '../hotkey-registry-controller';
|
||||
|
||||
let tagCounter = 0;
|
||||
|
||||
function uniqueTag(base: string): string {
|
||||
return `${base}-${tagCounter++}`;
|
||||
}
|
||||
|
||||
function createElement<Element extends HTMLElement>(Base: abstract new () => Element): Element {
|
||||
const tag = uniqueTag('test-el');
|
||||
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
|
||||
return document.createElement(tag) as Element;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('HotkeyElement', () => {
|
||||
it('has the correct tag name', () => {
|
||||
expect(HotkeyElement.tagName).toBe('media-hotkey');
|
||||
});
|
||||
|
||||
it('initializes with default property values', () => {
|
||||
const el = createElement(HotkeyElement);
|
||||
|
||||
expect(el.keys).toBe('');
|
||||
expect(el.action).toBe('');
|
||||
expect(el.value).toBeUndefined();
|
||||
expect(el.disabled).toBe(false);
|
||||
expect(el.target).toBe('player');
|
||||
});
|
||||
|
||||
it('is hidden when connected', () => {
|
||||
const el = createElement(HotkeyElement);
|
||||
document.body.appendChild(el);
|
||||
|
||||
expect(el.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HotkeyRegistryController', () => {
|
||||
it('returns undefined when no coordinator exists', () => {
|
||||
const el = createElement(HotkeyElement);
|
||||
document.body.appendChild(el);
|
||||
|
||||
const controller = new HotkeyRegistryController(el, 'togglePaused');
|
||||
|
||||
expect(controller.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import type { State } from '@videojs/store';
|
||||
|
||||
import type { PlayerController } from '../player/player-controller';
|
||||
import { HotkeyRegistryController } from './hotkey/hotkey-registry-controller';
|
||||
import { MediaElement } from './media-element';
|
||||
|
||||
/** Abstract base for HTML custom elements that render a media-control button. */
|
||||
@@ -28,15 +29,23 @@ export abstract class MediaButtonElement<Core extends MediaButtonComponent> exte
|
||||
|
||||
protected abstract activate(state: InferMediaState<Core>): void;
|
||||
|
||||
/** Override to set the hotkey action name for `aria-keyshortcuts`. */
|
||||
protected readonly hotkeyAction: string | undefined = undefined;
|
||||
|
||||
get $state(): State<ButtonState> {
|
||||
return this.core.state;
|
||||
}
|
||||
|
||||
#disconnect: AbortController | null = null;
|
||||
#hotkeyRegistry: HotkeyRegistryController | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this.hotkeyAction) {
|
||||
this.#hotkeyRegistry = new HotkeyRegistryController(this, this.hotkeyAction);
|
||||
}
|
||||
|
||||
this.#disconnect = new AbortController();
|
||||
|
||||
const buttonProps = createButton({
|
||||
@@ -76,7 +85,10 @@ export abstract class MediaButtonElement<Core extends MediaButtonComponent> exte
|
||||
|
||||
this.core.setMedia(media);
|
||||
const state = this.core.getState();
|
||||
applyElementProps(this, this.core.getAttrs?.(state) ?? {});
|
||||
applyElementProps(this, {
|
||||
...this.core.getAttrs?.(state),
|
||||
'aria-keyshortcuts': this.#hotkeyRegistry?.value,
|
||||
});
|
||||
applyStateDataAttrs(this, state, this.stateAttrMap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export class MuteButtonElement extends MediaButtonElement<MuteButtonCore> {
|
||||
protected readonly core = new MuteButtonCore();
|
||||
protected readonly stateAttrMap = MuteButtonDataAttrs;
|
||||
protected readonly mediaState = new PlayerController(this, playerContext, selectVolume);
|
||||
protected override readonly hotkeyAction = 'toggleMuted';
|
||||
|
||||
protected activate(state: MediaVolumeState): void {
|
||||
this.core.toggle(state);
|
||||
|
||||
@@ -11,6 +11,7 @@ export class PiPButtonElement extends MediaButtonElement<PiPButtonCore> {
|
||||
protected readonly core = new PiPButtonCore();
|
||||
protected readonly stateAttrMap = PiPButtonDataAttrs;
|
||||
protected readonly mediaState = new PlayerController(this, playerContext, selectPiP);
|
||||
protected override readonly hotkeyAction = 'togglePiP';
|
||||
|
||||
protected activate(state: MediaPictureInPictureState): void {
|
||||
this.core.toggle(state);
|
||||
|
||||
@@ -11,6 +11,7 @@ export class PlayButtonElement extends MediaButtonElement<PlayButtonCore> {
|
||||
protected readonly core = new PlayButtonCore();
|
||||
protected readonly stateAttrMap = PlayButtonDataAttrs;
|
||||
protected readonly mediaState = new PlayerController(this, playerContext, selectPlayback);
|
||||
protected override readonly hotkeyAction = 'togglePaused';
|
||||
|
||||
protected activate(state: MediaPlaybackState): void {
|
||||
this.core.toggle(state);
|
||||
|
||||
Reference in New Issue
Block a user