diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f1613a67..6946a6bc 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/ui/captions-button/captions-button.tsx b/packages/react/src/ui/captions-button/captions-button.tsx index bab3be13..1f3a5ec4 100644 --- a/packages/react/src/ui/captions-button/captions-button.tsx +++ b/packages/react/src/ui/captions-button/captions-button.tsx @@ -17,6 +17,7 @@ export const CaptionsButton = createMediaButton core.toggle(state), + hotkeyAction: 'toggleSubtitles', }); export namespace CaptionsButton { diff --git a/packages/react/src/ui/create-media-button.tsx b/packages/react/src/ui/create-media-button.tsx index 0f1ea83b..a07ba1c8 100644 --- a/packages/react/src/ui/create-media-button.tsx +++ b/packages/react/src/ui/create-media-button.tsx @@ -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> { @@ -18,13 +19,14 @@ interface MediaButtonConfig> { stateAttrMap: StateAttrMap>; selector: Selector | undefined>; action: (core: Core, state: InferMediaState) => void; + hotkeyAction?: string; } /** Creates a media button React component from a core class and config. */ export function createMediaButton, Props extends object>( config: MediaButtonConfig ): ForwardRefExoticComponent> { - 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, 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, P return null; } - const attrs = core.getAttrs(state); + const attrs = { ...core.getAttrs(state), 'aria-keyshortcuts': shortcuts }; return renderElement( 'button', diff --git a/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx b/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx index dcc30713..f86f80ac 100644 --- a/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx +++ b/packages/react/src/ui/fullscreen-button/fullscreen-button.tsx @@ -17,6 +17,7 @@ export const FullscreenButton = createMediaButton core.toggle(state), + hotkeyAction: 'toggleFullscreen', }); export namespace FullscreenButton { diff --git a/packages/react/src/ui/hotkey/media-hotkey.tsx b/packages/react/src/ui/hotkey/media-hotkey.tsx new file mode 100644 index 00000000..7d5cb2d1 --- /dev/null +++ b/packages/react/src/ui/hotkey/media-hotkey.tsx @@ -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; +} diff --git a/packages/react/src/ui/hotkey/use-aria-key-shortcuts.ts b/packages/react/src/ui/hotkey/use-aria-key-shortcuts.ts new file mode 100644 index 00000000..b98b6ae1 --- /dev/null +++ b/packages/react/src/ui/hotkey/use-aria-key-shortcuts.ts @@ -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]); +} diff --git a/packages/react/src/ui/hotkey/use-hotkey.ts b/packages/react/src/ui/hotkey/use-hotkey.ts new file mode 100644 index 00000000..4f071691 --- /dev/null +++ b/packages/react/src/ui/hotkey/use-hotkey.ts @@ -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]); +} diff --git a/packages/react/src/ui/mute-button/mute-button.tsx b/packages/react/src/ui/mute-button/mute-button.tsx index fc2ff044..d8801c4d 100644 --- a/packages/react/src/ui/mute-button/mute-button.tsx +++ b/packages/react/src/ui/mute-button/mute-button.tsx @@ -15,6 +15,7 @@ export const MuteButton = createMediaButton({ stateAttrMap: MuteButtonDataAttrs, selector: selectVolume, action: (core, state) => core.toggle(state), + hotkeyAction: 'toggleMuted', }); export namespace MuteButton { diff --git a/packages/react/src/ui/pip-button/pip-button.tsx b/packages/react/src/ui/pip-button/pip-button.tsx index 6d3966b1..aff33109 100644 --- a/packages/react/src/ui/pip-button/pip-button.tsx +++ b/packages/react/src/ui/pip-button/pip-button.tsx @@ -15,6 +15,7 @@ export const PiPButton = createMediaButton({ stateAttrMap: PiPButtonDataAttrs, selector: selectPiP, action: (core, state) => core.toggle(state), + hotkeyAction: 'togglePiP', }); export namespace PiPButton { diff --git a/packages/react/src/ui/play-button/play-button.tsx b/packages/react/src/ui/play-button/play-button.tsx index 80616882..26f8ec7c 100644 --- a/packages/react/src/ui/play-button/play-button.tsx +++ b/packages/react/src/ui/play-button/play-button.tsx @@ -30,6 +30,7 @@ export const PlayButton = createMediaButton({ stateAttrMap: PlayButtonDataAttrs, selector: selectPlayback, action: (core, state) => core.toggle(state), + hotkeyAction: 'togglePaused', }); export namespace PlayButton {