feat(react): add hotkeys (#1241)

This commit is contained in:
rahim
2026-04-07 12:28:25 -07:00
committed by GitHub
parent b1ddda05de
commit 1cc4ec7f81
10 changed files with 106 additions and 2 deletions
+3
View File
@@ -41,6 +41,9 @@ export { ErrorDialog, type ErrorDialogContextValue, useErrorDialogContext } from
export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button';
export { useButton } from './ui/hooks/use-button';
export { useSlider } from './ui/hooks/use-slider';
export { MediaHotkey, type MediaHotkeyProps } from './ui/hotkey/media-hotkey';
export { useAriaKeyShortcuts } from './ui/hotkey/use-aria-key-shortcuts';
export { type UseHotkeyOptions, useHotkey } from './ui/hotkey/use-hotkey';
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';
@@ -17,6 +17,7 @@ export const CaptionsButton = createMediaButton<CaptionsButtonCore, CaptionsButt
stateAttrMap: CaptionsButtonDataAttrs,
selector: selectTextTrack,
action: (core, state) => core.toggle(state),
hotkeyAction: 'toggleSubtitles',
});
export namespace CaptionsButton {
@@ -10,6 +10,7 @@ import { usePlayer } from '../player/context';
import type { renderElement as renderElementFn } from '../utils/use-render';
import { renderElement } from '../utils/use-render';
import { useButton } from './hooks/use-button';
import { useAriaKeyShortcuts } from './hotkey/use-aria-key-shortcuts';
import { useOptionalTooltipContext } from './tooltip/context';
interface MediaButtonConfig<Core extends Required<MediaButtonComponent>> {
@@ -18,13 +19,14 @@ interface MediaButtonConfig<Core extends Required<MediaButtonComponent>> {
stateAttrMap: StateAttrMap<InferComponentState<Core>>;
selector: Selector<object, InferMediaState<Core> | undefined>;
action: (core: Core, state: InferMediaState<Core>) => void;
hotkeyAction?: string;
}
/** Creates a media button React component from a core class and config. */
export function createMediaButton<Core extends Required<MediaButtonComponent>, Props extends object>(
config: MediaButtonConfig<Core>
): ForwardRefExoticComponent<Props & RefAttributes<HTMLButtonElement>> {
const { displayName, core: CoreClass, stateAttrMap, selector, action } = config;
const { displayName, core: CoreClass, stateAttrMap, selector, action, hotkeyAction } = config;
// Props that exist in the core's defaultProps are routed to setProps; the rest go to the DOM element.
const corePropKeys = new Set(Object.keys(CoreClass.defaultProps));
@@ -48,6 +50,7 @@ export function createMediaButton<Core extends Required<MediaButtonComponent>, P
const tooltipCtx = useOptionalTooltipContext();
const feature = usePlayer(selector);
const shortcuts = useAriaKeyShortcuts(hotkeyAction);
const [core] = useState(() => new CoreClass());
core.setProps(coreProps);
@@ -77,7 +80,7 @@ export function createMediaButton<Core extends Required<MediaButtonComponent>, P
return null;
}
const attrs = core.getAttrs(state);
const attrs = { ...core.getAttrs(state), 'aria-keyshortcuts': shortcuts };
return renderElement(
'button',
@@ -17,6 +17,7 @@ export const FullscreenButton = createMediaButton<FullscreenButtonCore, Fullscre
stateAttrMap: FullscreenButtonDataAttrs,
selector: selectFullscreen,
action: (core, state) => core.toggle(state),
hotkeyAction: 'toggleFullscreen',
});
export namespace FullscreenButton {
@@ -0,0 +1,46 @@
'use client';
import {
type AnyPlayerStore,
createHotkey,
type HotkeyActionName,
isHotkeyToggleAction,
resolveHotkeyAction,
} from '@videojs/core/dom';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { useContainer, usePlayer } from '../../player/context';
export interface MediaHotkeyProps {
keys: string;
action: HotkeyActionName | (string & {});
value?: number;
disabled?: boolean;
target?: 'player' | 'document';
}
export function MediaHotkey({ keys, action, value, disabled, target }: MediaHotkeyProps): ReactNode {
const store = usePlayer() as AnyPlayerStore;
const container = useContainer();
useEffect(() => {
if (!container || !keys || !action || disabled) return;
const resolver = resolveHotkeyAction(action);
if (!resolver) return;
return createHotkey(container, {
keys,
action,
target,
disabled,
repeatable: !isHotkeyToggleAction(action),
onActivate: (_event, key) => {
resolver({ store, key, value });
},
});
}, [container, store, keys, action, value, disabled, target]);
return null;
}
@@ -0,0 +1,14 @@
'use client';
import { findHotkeyCoordinator } from '@videojs/core/dom';
import { useMemo } from 'react';
import { useContainer } from '../../player/context';
export function useAriaKeyShortcuts(action: string | undefined): string | undefined {
const container = useContainer();
return useMemo(() => {
if (!container || !action) return undefined;
return findHotkeyCoordinator(container)?.getAriaKeys(action);
}, [container, action]);
}
@@ -0,0 +1,33 @@
'use client';
import { createHotkey } from '@videojs/core/dom';
import { useEffect } from 'react';
import { useContainer } from '../../player/context';
import { useLatestRef } from '../../utils/use-latest-ref';
export interface UseHotkeyOptions {
keys: string;
onActivate: (event: KeyboardEvent, key: string) => void;
target?: 'player' | 'document';
repeatable?: boolean;
disabled?: boolean;
}
export function useHotkey(options: UseHotkeyOptions): void {
const { keys, target = 'player', repeatable = true, disabled = false } = options;
const container = useContainer();
const onActivateRef = useLatestRef(options.onActivate);
useEffect(() => {
if (!container || !keys || disabled) return;
return createHotkey(container, {
keys,
target,
repeatable,
disabled,
onActivate: (event, key) => onActivateRef.current(event, key),
});
}, [container, keys, target, repeatable, disabled, onActivateRef]);
}
@@ -15,6 +15,7 @@ export const MuteButton = createMediaButton<MuteButtonCore, MuteButtonProps>({
stateAttrMap: MuteButtonDataAttrs,
selector: selectVolume,
action: (core, state) => core.toggle(state),
hotkeyAction: 'toggleMuted',
});
export namespace MuteButton {
@@ -15,6 +15,7 @@ export const PiPButton = createMediaButton<PiPButtonCore, PiPButtonProps>({
stateAttrMap: PiPButtonDataAttrs,
selector: selectPiP,
action: (core, state) => core.toggle(state),
hotkeyAction: 'togglePiP',
});
export namespace PiPButton {
@@ -30,6 +30,7 @@ export const PlayButton = createMediaButton<PlayButtonCore, PlayButtonProps>({
stateAttrMap: PlayButtonDataAttrs,
selector: selectPlayback,
action: (core, state) => core.toggle(state),
hotkeyAction: 'togglePaused',
});
export namespace PlayButton {