import { globalThis } from './utils/server-safe-globals.js'; import { MediaChromeDialog } from './media-chrome-dialog.js'; import { getStringAttr, setStringAttr } from './utils/element-utils.js'; export const Attributes = { HOTKEYS: 'hotkeys', }; type Shortcut = { /** * `key` is what the controller matches on (the `KeyboardEvent.key` value, or * `shift+/` for the combo), `label` how it is displayed. */ keys: { key: string; label?: string }[]; description: string; }; /** * The keyboard shortcuts this dialog advertises, in display order. * Keep in sync with `keyboardShortcutHandler()` in `media-controller.ts`. */ const shortcuts: Shortcut[] = [ { keys: [{ key: ' ', label: 'Space' }, { key: 'k' }], description: 'Toggle Playback', }, { keys: [{ key: 'm' }], description: 'Toggle mute' }, { keys: [{ key: 'f' }], description: 'Toggle fullscreen' }, { keys: [{ key: 'c' }], description: 'Toggle captions or subtitles, if available', }, { keys: [{ key: 'p' }], description: 'Toggle Picture in Picture' }, { keys: [{ key: 'ArrowLeft', label: '←' }, { key: 'j' }], description: 'Seek back 10s', }, { keys: [{ key: 'ArrowRight', label: '→' }, { key: 'l' }], description: 'Seek forward 10s', }, { keys: [{ key: 'ArrowUp', label: '↑' }], description: 'Turn volume up' }, { keys: [{ key: 'ArrowDown', label: '↓' }], description: 'Turn volume down' }, { keys: [{ key: '<', label: '< (SHIFT+,)' }], description: 'Decrease playback rate', }, { keys: [{ key: '>', label: '> (SHIFT+.)' }], description: 'Increase playback rate', }, { keys: [{ key: 'shift+/', label: 'SHIFT+/' }], description: 'Show keyboard shortcuts', }, ]; /** * The `hotkeys` token that turns `key` off. Shared with ``, * which uses it to check its blocklist. */ export function getHotkeyToken(key: string) { return key === ' ' ? 'nospace' : `no${key.toLowerCase()}`; } /** * Filtering is per key rather than per row, matching how the hotkeys are * blocked: `hotkeys="nok"` leaves `Space` listed for Toggle Playback, and only * `hotkeys="nok nospace"` drops the row entirely. */ function getEnabledShortcuts(hotkeys?: string | null): Shortcut[] { const disabled = new Set(hotkeys?.split(/\s+/)); return shortcuts .map(({ keys, description }) => ({ keys: keys.filter(({ key }) => !disabled.has(getHotkeyToken(key))), description, })) .filter(({ keys }) => keys.length); } function getSlotTemplateHTML(attrs: Record) { return /*html*/ ` ${formatKeyboardShortcuts(attrs[Attributes.HOTKEYS])} `; } function formatKeyboardShortcuts(hotkeys?: string | null) { // Should always have at least one row because the hotkey for this dialog itself is present; // if it isn't, the dialog should close. const rows = getEnabledShortcuts(hotkeys) .map(({ keys, description }) => { const keyCombo = keys .map(({ key, label = key }, index) => index > 0 ? `or${label}` : `${label}` ) .join(''); return `
${keyCombo}
${description} `; }) .join(''); return `

Keyboard Shortcuts

${rows}
`; } /** * @extends {MediaChromeDialog} * * @attr {string} hotkeys - Space separated list of `no*` hotkey tokens (e.g. * `nof noarrowleft`), as used by ``. Shortcuts turned off * here are omitted from the dialog. */ class MediaKeyboardShortcutsDialog extends MediaChromeDialog { static getSlotTemplateHTML = getSlotTemplateHTML; static get observedAttributes() { return [...super.observedAttributes, Attributes.HOTKEYS]; } get hotkeys(): string | undefined { return getStringAttr(this, Attributes.HOTKEYS) ?? undefined; } set hotkeys(value: string | undefined) { setStringAttr(this, Attributes.HOTKEYS, value); } connectedCallback() { super.connectedCallback(); if (this.open) { this.addEventListener('click', this.#clickHandler); document.addEventListener('keydown', this.#keyDownHandler); } } disconnectedCallback() { this.removeEventListener('click', this.#clickHandler); document.removeEventListener('keydown', this.#keyDownHandler); } attributeChangedCallback( attrName: string, oldValue: string | null, newValue: string | null ) { super.attributeChangedCallback(attrName, oldValue, newValue); if (attrName === 'open') { if (this.open) { this.addEventListener('click', this.#clickHandler); document.addEventListener('keydown', this.#keyDownHandler); } else { this.removeEventListener('click', this.#clickHandler); document.removeEventListener('keydown', this.#keyDownHandler); } } else if (attrName === Attributes.HOTKEYS && newValue !== oldValue) { const content = this.shadowRoot?.querySelector('#content'); if (content) content.innerHTML = formatKeyboardShortcuts(newValue); } } #clickHandler = (e: MouseEvent) => { if (!this.open) return; const content = this.shadowRoot?.querySelector('#content'); if (!content) return; // Check if click is on the background (outside content) const path = e.composedPath(); const isClickOnHost = path[0] === this || path.includes(this); const isClickInsideContent = path.includes(content); if (isClickOnHost && !isClickInsideContent) { this.open = false; } }; #keyDownHandler = (e: KeyboardEvent) => { if (!this.open) return; const isShiftSlash = e.shiftKey && (e.key === '/' || e.key === '?'); if ( (e.key === 'Escape' || isShiftSlash) && !e.ctrlKey && !e.altKey && !e.metaKey ) { this.open = false; e.preventDefault(); e.stopPropagation(); } }; } if (!globalThis.customElements.get('media-keyboard-shortcuts-dialog')) { globalThis.customElements.define( 'media-keyboard-shortcuts-dialog', MediaKeyboardShortcutsDialog ); } export { MediaKeyboardShortcutsDialog }; export default MediaKeyboardShortcutsDialog;