/* The can contain the control elements and the media element. Features: * Auto-set the `media` attribute on child media chrome elements * Uses the element with slot="media" * Take custom controls to fullscreen * Position controls at the bottom * Auto-hide controls on inactivity while playing */ import { MediaContainer } from './media-container.js'; import { document, globalThis } from './utils/server-safe-globals.js'; import { MediaKeyboardShortcutsDialog } from './media-keyboard-shortcuts-dialog.js'; import { AttributeTokenList } from './utils/attribute-token-list.js'; import { delay, stringifyRenditionList, stringifyAudioTrackList, } from './utils/utils.js'; import { stringifyTextTrackList } from './utils/captions.js'; import { MediaUIEvents, MediaUIAttributes, MediaStateReceiverAttributes, AttributeToStateChangeEventMap, MediaUIProps, } from './constants.js'; import { getBooleanAttr, getNumericAttr, getStringAttr, setBooleanAttr, setNumericAttr, setStringAttr, } from './utils/element-utils.js'; import { createMediaStore, MediaStore } from './media-store/media-store.js'; import { CustomElement } from './utils/CustomElement.js'; import { setLanguage } from './utils/i18n.js'; const ButtonPressedKeys = [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' ', 'f', 'm', 'k', 'c', 'l', 'j', '>', '<', 'p', ]; const DEFAULT_SEEK_OFFSET = 10; const DEFAULT_VOLUME_STEP = 0.025; const DEFAULT_PLAYBACK_RATE_STEP = 0.25; const MIN_PLAYBACK_RATE = 0.25; const MAX_PLAYBACK_RATE = 2; export const Attributes = { DEFAULT_SUBTITLES: 'defaultsubtitles', DEFAULT_STREAM_TYPE: 'defaultstreamtype', DEFAULT_DURATION: 'defaultduration', FULLSCREEN_ELEMENT: 'fullscreenelement', HOTKEYS: 'hotkeys', KEYBOARD_BACKWARD_SEEK_OFFSET: 'keyboardbackwardseekoffset', KEYBOARD_FORWARD_SEEK_OFFSET: 'keyboardforwardseekoffset', KEYBOARD_DOWN_VOLUME_STEP: 'keyboarddownvolumestep', KEYBOARD_UP_VOLUME_STEP: 'keyboardupvolumestep', KEYS_USED: 'keysused', LANG: 'lang', LOOP: 'loop', LIVE_EDGE_OFFSET: 'liveedgeoffset', NO_AUTO_SEEK_TO_LIVE: 'noautoseektolive', NO_DEFAULT_STORE: 'nodefaultstore', NO_HOTKEYS: 'nohotkeys', NO_MUTED_PREF: 'nomutedpref', NO_SUBTITLES_LANG_PREF: 'nosubtitleslangpref', NO_VOLUME_PREF: 'novolumepref', SEEK_TO_LIVE_OFFSET: 'seektoliveoffset', }; /** * Media Controller should not mimic the HTMLMediaElement API. * @see https://github.com/muxinc/media-chrome/pull/182#issuecomment-1067370339 * * @attr {boolean} defaultsubtitles * @attr {string} defaultstreamtype * @attr {string} defaultduration * @attr {string} fullscreenelement * @attr {boolean} nohotkeys * @attr {string} hotkeys * @attr {string} keysused * @attr {string} liveedgeoffset * @attr {string} seektoliveoffset * @attr {boolean} noautoseektolive * @attr {boolean} novolumepref * @attr {boolean} nomutedpref * @attr {boolean} nosubtitleslangpref * @attr {boolean} nodefaultstore * @attr {string} lang */ class MediaController extends MediaContainer { static get observedAttributes() { return super.observedAttributes.concat( Attributes.NO_HOTKEYS, Attributes.HOTKEYS, Attributes.DEFAULT_STREAM_TYPE, Attributes.DEFAULT_SUBTITLES, Attributes.DEFAULT_DURATION, Attributes.NO_MUTED_PREF, Attributes.NO_VOLUME_PREF, Attributes.LANG, Attributes.LOOP, Attributes.LIVE_EDGE_OFFSET, Attributes.SEEK_TO_LIVE_OFFSET, Attributes.NO_AUTO_SEEK_TO_LIVE ); } mediaStateReceivers: HTMLElement[] = []; associatedElementSubscriptions: Map void> = new Map(); #hotKeys = new AttributeTokenList(this, Attributes.HOTKEYS); #fullscreenElement: HTMLElement; #mediaStore: MediaStore; #keyboardShortcutsDialog: MediaKeyboardShortcutsDialog | null = null; #mediaStateCallback: (nextState: any) => void; #mediaStoreUnsubscribe: () => void; #mediaStateEventHandler = (event): void => { this.#mediaStore?.dispatch(event); }; #subtitlesState: boolean | undefined = undefined; constructor() { super(); // Track externally associated control elements this.associateElement(this); let prevState = {}; this.#mediaStateCallback = (nextState: any): void => { Object.entries(nextState).forEach(([stateName, stateValue]) => { // Make sure to propagate initial state, even if still undefined (CJP) if (stateName in prevState && prevState[stateName] === stateValue) return; this.propagateMediaState(stateName, stateValue); const attrName = stateName.toLowerCase(); const evt = new globalThis.CustomEvent( AttributeToStateChangeEventMap[attrName], { composed: true, detail: stateValue } ); this.dispatchEvent(evt); }); prevState = nextState; }; } #setupDefaultStore() { this.mediaStore = createMediaStore({ media: this.media, fullscreenElement: this.fullscreenElement, options: { defaultSubtitles: this.hasAttribute(Attributes.DEFAULT_SUBTITLES), defaultDuration: this.hasAttribute(Attributes.DEFAULT_DURATION) ? +this.getAttribute(Attributes.DEFAULT_DURATION) : undefined, defaultStreamType: /** @type {import('./media-store/state-mediator.js').StreamTypeValue} */ this.getAttribute( Attributes.DEFAULT_STREAM_TYPE ) ?? undefined, liveEdgeOffset: this.hasAttribute(Attributes.LIVE_EDGE_OFFSET) ? +this.getAttribute(Attributes.LIVE_EDGE_OFFSET) : undefined, seekToLiveOffset: this.hasAttribute(Attributes.SEEK_TO_LIVE_OFFSET) ? +this.getAttribute(Attributes.SEEK_TO_LIVE_OFFSET) : this.hasAttribute(Attributes.LIVE_EDGE_OFFSET) ? +this.getAttribute(Attributes.LIVE_EDGE_OFFSET) : undefined, noAutoSeekToLive: this.hasAttribute(Attributes.NO_AUTO_SEEK_TO_LIVE), // NOTE: This wasn't updated if it was changed later. Should it be? (CJP) noVolumePref: this.hasAttribute(Attributes.NO_VOLUME_PREF), noMutedPref: this.hasAttribute(Attributes.NO_MUTED_PREF), noSubtitlesLangPref: this.hasAttribute( Attributes.NO_SUBTITLES_LANG_PREF ), }, }); } get mediaStore(): MediaStore { return this.#mediaStore; } set mediaStore(value: MediaStore) { if (this.#mediaStore) { this.#mediaStoreUnsubscribe?.(); this.#mediaStoreUnsubscribe = undefined; } this.#mediaStore = value; if (!this.#mediaStore && !this.hasAttribute(Attributes.NO_DEFAULT_STORE)) { this.#setupDefaultStore(); return; } this.#mediaStoreUnsubscribe = this.#mediaStore?.subscribe( this.#mediaStateCallback ); } get fullscreenElement(): HTMLElement { return this.#fullscreenElement ?? this; } set fullscreenElement(element: HTMLElement) { if (this.hasAttribute(Attributes.FULLSCREEN_ELEMENT)) { this.removeAttribute(Attributes.FULLSCREEN_ELEMENT); } this.#fullscreenElement = element; // Use the getter in case the fullscreen element was reset to "`this`" this.#mediaStore?.dispatch({ type: 'fullscreenelementchangerequest', detail: this.fullscreenElement, }); } get defaultSubtitles(): boolean | undefined { return getBooleanAttr(this, Attributes.DEFAULT_SUBTITLES); } set defaultSubtitles(value: boolean) { setBooleanAttr(this, Attributes.DEFAULT_SUBTITLES, value); } get defaultStreamType(): string | undefined { return getStringAttr(this, Attributes.DEFAULT_STREAM_TYPE); } set defaultStreamType(value: string | undefined) { setStringAttr(this, Attributes.DEFAULT_STREAM_TYPE, value); } get defaultDuration(): number | undefined { return getNumericAttr(this, Attributes.DEFAULT_DURATION); } set defaultDuration(value: number | undefined) { setNumericAttr(this, Attributes.DEFAULT_DURATION, value); } get noHotkeys(): boolean | undefined { return getBooleanAttr(this, Attributes.NO_HOTKEYS); } set noHotkeys(value: boolean | undefined) { setBooleanAttr(this, Attributes.NO_HOTKEYS, value); } get keysUsed(): string | undefined { return getStringAttr(this, Attributes.KEYS_USED); } set keysUsed(value: string | undefined) { setStringAttr(this, Attributes.KEYS_USED, value); } get liveEdgeOffset(): number | undefined { return getNumericAttr(this, Attributes.LIVE_EDGE_OFFSET); } set liveEdgeOffset(value: number | undefined) { setNumericAttr(this, Attributes.LIVE_EDGE_OFFSET, value); } get noAutoSeekToLive(): boolean | undefined { return getBooleanAttr(this, Attributes.NO_AUTO_SEEK_TO_LIVE); } set noAutoSeekToLive(value: boolean | undefined) { setBooleanAttr(this, Attributes.NO_AUTO_SEEK_TO_LIVE, value); } get noVolumePref(): boolean | undefined { return getBooleanAttr(this, Attributes.NO_VOLUME_PREF); } set noVolumePref(value: boolean | undefined) { setBooleanAttr(this, Attributes.NO_VOLUME_PREF, value); } get noMutedPref(): boolean | undefined { return getBooleanAttr(this, Attributes.NO_MUTED_PREF); } set noMutedPref(value: boolean | undefined) { setBooleanAttr(this, Attributes.NO_MUTED_PREF, value); } get noSubtitlesLangPref(): boolean | undefined { return getBooleanAttr(this, Attributes.NO_SUBTITLES_LANG_PREF); } set noSubtitlesLangPref(value: boolean | undefined) { setBooleanAttr(this, Attributes.NO_SUBTITLES_LANG_PREF, value); } get noDefaultStore(): boolean | undefined { return getBooleanAttr(this, Attributes.NO_DEFAULT_STORE); } set noDefaultStore(value: boolean | undefined) { setBooleanAttr(this, Attributes.NO_DEFAULT_STORE, value); } attributeChangedCallback( attrName: string, oldValue: string | null, newValue: string | null ): void { super.attributeChangedCallback(attrName, oldValue, newValue); if (attrName === Attributes.NO_HOTKEYS) { if (newValue !== oldValue && newValue === '') { if (this.hasAttribute(Attributes.HOTKEYS)) { console.warn( 'Media Chrome: Both `hotkeys` and `nohotkeys` have been set. All hotkeys will be disabled.' ); } this.disableHotkeys(); } else if (newValue !== oldValue && newValue === null) { this.enableHotkeys(); } } else if (attrName === Attributes.HOTKEYS) { this.#hotKeys.value = newValue; } else if ( attrName === Attributes.DEFAULT_SUBTITLES && newValue !== oldValue ) { this.#mediaStore?.dispatch({ type: 'optionschangerequest', detail: { defaultSubtitles: this.hasAttribute(Attributes.DEFAULT_SUBTITLES), }, }); } else if (attrName === Attributes.DEFAULT_STREAM_TYPE) { this.#mediaStore?.dispatch({ type: 'optionschangerequest', detail: { defaultStreamType: this.getAttribute(Attributes.DEFAULT_STREAM_TYPE) ?? undefined, }, }); } else if (attrName === Attributes.LIVE_EDGE_OFFSET && newValue !== oldValue) { this.#mediaStore?.dispatch({ type: 'optionschangerequest', detail: { liveEdgeOffset: this.hasAttribute(Attributes.LIVE_EDGE_OFFSET) ? +this.getAttribute(Attributes.LIVE_EDGE_OFFSET) : undefined, seekToLiveOffset: this.hasAttribute(Attributes.SEEK_TO_LIVE_OFFSET) ? +this.getAttribute(Attributes.SEEK_TO_LIVE_OFFSET) : this.hasAttribute(Attributes.LIVE_EDGE_OFFSET) ? +this.getAttribute(Attributes.LIVE_EDGE_OFFSET) : undefined, }, }); } else if ( attrName === Attributes.SEEK_TO_LIVE_OFFSET && newValue !== oldValue ) { this.#mediaStore?.dispatch({ type: 'optionschangerequest', detail: { // Mirror #setupDefaultStore: prefer seektoliveoffset, fall back to // liveedgeoffset, otherwise undefined. seekToLiveOffset: this.hasAttribute(Attributes.SEEK_TO_LIVE_OFFSET) ? +this.getAttribute(Attributes.SEEK_TO_LIVE_OFFSET) : this.hasAttribute(Attributes.LIVE_EDGE_OFFSET) ? +this.getAttribute(Attributes.LIVE_EDGE_OFFSET) : undefined, }, }); } else if (attrName === Attributes.NO_AUTO_SEEK_TO_LIVE) { this.#mediaStore?.dispatch({ type: 'optionschangerequest', detail: { noAutoSeekToLive: this.hasAttribute(Attributes.NO_AUTO_SEEK_TO_LIVE), }, }); } else if (attrName === Attributes.FULLSCREEN_ELEMENT) { const el: HTMLElement = newValue ? (this.getRootNode() as Document)?.getElementById(newValue) : undefined; // NOTE: Setting the internal private prop here instead of using the setter to not // clear the attribute that was just set (CJP). this.#fullscreenElement = el; // Use the getter in case the fullscreen element was reset to "`this`" this.#mediaStore?.dispatch({ type: 'fullscreenelementchangerequest', detail: this.fullscreenElement, }); } else if (attrName === Attributes.LANG && newValue !== oldValue) { setLanguage(newValue); this.#mediaStore?.dispatch({ type: 'optionschangerequest', detail: { mediaLang: newValue, }, }); } else if (attrName === Attributes.LOOP && newValue !== oldValue) { this.#mediaStore?.dispatch({ type: MediaUIEvents.MEDIA_LOOP_REQUEST, detail: newValue != null, }); } else if (attrName === Attributes.NO_VOLUME_PREF && newValue !== oldValue) { this.#mediaStore?.dispatch({ type: 'optionschangerequest', detail: { noVolumePref: this.hasAttribute(Attributes.NO_VOLUME_PREF), }, }); } else if (attrName === Attributes.NO_MUTED_PREF && newValue !== oldValue) { this.#mediaStore?.dispatch({ type: 'optionschangerequest', detail: { noMutedPref: this.hasAttribute(Attributes.NO_MUTED_PREF), }, }); } } connectedCallback(): void { this.associateElement(this); // NOTE: Need to defer default MediaStore creation until connected for use cases that // rely on createElement('media-controller') (like many frameworks "under the hood") (CJP). if (!this.#mediaStore && !this.hasAttribute(Attributes.NO_DEFAULT_STORE)) { this.#setupDefaultStore(); } this.#mediaStore?.dispatch({ type: 'documentelementchangerequest', detail: document, }); this.#mediaStore?.dispatch({ type: 'fullscreenelementchangerequest', detail: this.fullscreenElement, }); // mediaSetCallback() is called in super.connectedCallback(); super.connectedCallback(); if (this.#mediaStore && !this.#mediaStoreUnsubscribe) { this.#mediaStoreUnsubscribe = this.#mediaStore?.subscribe( this.#mediaStateCallback ); } // Restore subtitles state if it was saved before disconnecting if (this.#subtitlesState !== undefined && this.#mediaStore && this.media) { // Wait for mediaStore to sync, then try to restore setTimeout(() => { if (this.media?.textTracks?.length) { this.#mediaStore?.dispatch({ type: MediaUIEvents.MEDIA_TOGGLE_SUBTITLES_REQUEST, detail: this.#subtitlesState, }); } }, 0); } this.hasAttribute(Attributes.NO_HOTKEYS) ? this.disableHotkeys() : this.enableHotkeys(); } disconnectedCallback(): void { // mediaUnsetCallback() is called in super.disconnectedCallback(); super.disconnectedCallback?.(); this.disableHotkeys(); if (this.#mediaStore) { // Save the current state of subtitles before disconnecting const currentState = this.#mediaStore.getState(); this.#subtitlesState = !!currentState.mediaSubtitlesShowing?.length; // Clear all stateOwners to teardown event handlers and release DOM references. // Note: mediaelementchangerequest is already dispatched by super.disconnectedCallback() via mediaUnsetCallback. this.#mediaStore?.dispatch({ type: 'fullscreenelementchangerequest', detail: undefined, }); this.#mediaStore?.dispatch({ type: 'documentelementchangerequest', detail: undefined, }); /** @TODO Revisit: may not be necessary anymore or better solved via unsubscribe behavior? (CJP) */ // Disable captions on disconnect to prevent a memory leak if they stay enabled. this.#mediaStore?.dispatch({ type: MediaUIEvents.MEDIA_TOGGLE_SUBTITLES_REQUEST, detail: false, }); } if (this.#mediaStoreUnsubscribe) { this.#mediaStoreUnsubscribe?.(); this.#mediaStoreUnsubscribe = undefined; } this.unassociateElement(this); if (this.#keyboardShortcutsDialog) { this.#keyboardShortcutsDialog.remove(); this.#keyboardShortcutsDialog = null; } } /** * @override * @param {HTMLMediaElement} media */ mediaSetCallback(media: HTMLMediaElement) { super.mediaSetCallback(media); this.#mediaStore?.dispatch({ type: 'mediaelementchangerequest', detail: media, }); /* * Prevents the media element from being tab focusable to avoid the blue focus ring, * particularly when going full screen. The media controller handles all accessibility * responsibilities (clickable, keyboard controls, etc.) instead. * * See related links: * - https://github.com/muxinc/media-chrome/issues/309 * - https://github.com/muxinc/media-chrome/pull/312 */ if (!media.hasAttribute('tabindex')) { media.tabIndex = -1; } } /** * @override * @param {HTMLMediaElement} media */ mediaUnsetCallback(media: HTMLMediaElement) { super.mediaUnsetCallback(media); this.#mediaStore?.dispatch({ type: 'mediaelementchangerequest', detail: undefined, }); } propagateMediaState(stateName: string, state: any) { propagateMediaState(this.mediaStateReceivers, stateName, state); } associateElement(element: HTMLElement) { if (!element) return; const { associatedElementSubscriptions } = this; if (associatedElementSubscriptions.has(element)) return; const registerMediaStateReceiver = this.registerMediaStateReceiver.bind(this); const unregisterMediaStateReceiver = this.unregisterMediaStateReceiver.bind(this); /** @TODO Should we support "removing association" */ const unsubscribe = monitorForMediaStateReceivers( element, registerMediaStateReceiver, unregisterMediaStateReceiver ); // Add all media request event listeners to the Associated Element. This allows any DOM element that // is a descendant of any Associated Element (including the itself) to make requests // for media state changes rather than constraining that exclusively to a Media State Receivers. // Still generically setup events -> mediaStore dispatch, since it will // forward the events on to whichever store is defined (CJP) Object.values(MediaUIEvents).forEach((eventName) => { element.addEventListener(eventName, this.#mediaStateEventHandler); }); associatedElementSubscriptions.set(element, unsubscribe); } unassociateElement(element: HTMLElement) { if (!element) return; const { associatedElementSubscriptions } = this; if (!associatedElementSubscriptions.has(element)) return; const unsubscribe = associatedElementSubscriptions.get(element); unsubscribe(); associatedElementSubscriptions.delete(element); // Remove all media UI event listeners Object.values(MediaUIEvents).forEach((eventName) => { element.removeEventListener(eventName, this.#mediaStateEventHandler); }); } registerMediaStateReceiver(el: HTMLElement) { if (!el) return; const els = this.mediaStateReceivers; const index = els.indexOf(el); if (index > -1) return; els.push(el); if (this.#mediaStore) { Object.entries(this.#mediaStore.getState()).forEach( ([stateName, stateValue]) => { propagateMediaState([el], stateName, stateValue); } ); } } unregisterMediaStateReceiver(el: HTMLElement) { const els = this.mediaStateReceivers; const index = els.indexOf(el); if (index < 0) return; els.splice(index, 1); } #keyUpHandler = (e: KeyboardEvent) => { const { key, shiftKey } = e; // Check for Shift + / (which produces '?' on US keyboards or '/' on others) const isShiftSlash = shiftKey && (key === '/' || key === '?'); const shouldHandle = isShiftSlash || ButtonPressedKeys.includes(key); if (!shouldHandle) { this.removeEventListener('keyup', this.#keyUpHandler); return; } this.keyboardShortcutHandler(e); } #keyDownHandler(e: KeyboardEvent) { const { metaKey, altKey, key, shiftKey } = e; // Check for Shift + / (which produces '?' on US keyboards or '/' on others) const isShiftSlash = shiftKey && (key === '/' || key === '?'); // If dialog is open, remove keyup handler - the dialog will handle closing itself if (isShiftSlash && this.#keyboardShortcutsDialog?.open) { this.removeEventListener('keyup', this.#keyUpHandler); return; } if (metaKey || altKey || (!isShiftSlash && !ButtonPressedKeys.includes(key))) { this.removeEventListener('keyup', this.#keyUpHandler); return; } const target = e.target; const isRangeInput = target instanceof HTMLElement && (target.tagName.toLowerCase() === 'media-volume-range' || target.tagName.toLowerCase() === 'media-time-range'); // if the pressed key might move the page, we need to preventDefault on keydown // because doing so on keyup is too late // We also want to make sure that the hotkey hasn't been turned off before doing so if ( [' ', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key) && !( this.#hotKeys.contains(`no${key.toLowerCase()}`) || (key === ' ' && this.#hotKeys.contains('nospace')) ) && !isRangeInput // Only preventDefault if a range input is NOT selected ) { e.preventDefault(); } this.addEventListener('keyup', this.#keyUpHandler, { once: true }); } enableHotkeys() { this.addEventListener('keydown', this.#keyDownHandler); } disableHotkeys() { this.removeEventListener('keydown', this.#keyDownHandler); this.removeEventListener('keyup', this.#keyUpHandler); } // Added string to support JSX compatibility get hotkeys(): AttributeTokenList | string { return this.#hotKeys; } set hotkeys(value: string | undefined) { setStringAttr(this, Attributes.HOTKEYS, value); } keyboardShortcutHandler(e: KeyboardEvent) { // TODO: e.target might need to be replaced w/ e.composedPath to account for shadow DOM. // if the event's key is already handled by the target, skip keyboard shortcuts // keysUsed is either an attribute or a property. // The attribute is a DOM array and the property is a JS array // In the attribute Space represents the space key and gets convered to ' ' const target = e.target as any; const keysUsed = ( target.getAttribute(Attributes.KEYS_USED)?.split(' ') ?? target?.keysUsed ?? [] ) .map((key) => (key === 'Space' ? ' ' : key)) .filter(Boolean); if (keysUsed.includes(e.key)) { return; } let eventName, detail, evt; // if the blocklist contains the key, skip handling it. if (this.#hotKeys.contains(`no${e.key.toLowerCase()}`)) return; if (e.key === ' ' && this.#hotKeys.contains(`nospace`)) return; const isShiftSlash = e.shiftKey && (e.key === '/' || e.key === '?'); if (isShiftSlash && this.#hotKeys.contains('noshift+/')) return; // These event triggers were copied from the revelant buttons switch (e.key) { case ' ': case 'k': eventName = this.#mediaStore.getState().mediaPaused ? MediaUIEvents.MEDIA_PLAY_REQUEST : MediaUIEvents.MEDIA_PAUSE_REQUEST; this.dispatchEvent( new globalThis.CustomEvent(eventName, { composed: true, bubbles: true, }) ); break; case 'm': eventName = this.mediaStore.getState().mediaVolumeLevel === 'off' ? MediaUIEvents.MEDIA_UNMUTE_REQUEST : MediaUIEvents.MEDIA_MUTE_REQUEST; this.dispatchEvent( new globalThis.CustomEvent(eventName, { composed: true, bubbles: true, }) ); break; case 'f': eventName = this.mediaStore.getState().mediaIsFullscreen ? MediaUIEvents.MEDIA_EXIT_FULLSCREEN_REQUEST : MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST; this.dispatchEvent( new globalThis.CustomEvent(eventName, { composed: true, bubbles: true, }) ); break; case 'c': this.dispatchEvent( new globalThis.CustomEvent( MediaUIEvents.MEDIA_TOGGLE_SUBTITLES_REQUEST, { composed: true, bubbles: true } ) ); break; case 'ArrowLeft': case 'j': { const offsetValue = this.hasAttribute( Attributes.KEYBOARD_BACKWARD_SEEK_OFFSET ) ? +this.getAttribute(Attributes.KEYBOARD_BACKWARD_SEEK_OFFSET) : DEFAULT_SEEK_OFFSET; detail = Math.max( (this.mediaStore.getState().mediaCurrentTime ?? 0) - offsetValue, 0 ); evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, { composed: true, bubbles: true, detail, }); this.dispatchEvent(evt); break; } case 'ArrowRight': case 'l': { const offsetValue = this.hasAttribute( Attributes.KEYBOARD_FORWARD_SEEK_OFFSET ) ? +this.getAttribute(Attributes.KEYBOARD_FORWARD_SEEK_OFFSET) : DEFAULT_SEEK_OFFSET; detail = Math.max( (this.mediaStore.getState().mediaCurrentTime ?? 0) + offsetValue, 0 ); evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, { composed: true, bubbles: true, detail, }); this.dispatchEvent(evt); break; } case 'ArrowUp': { const step = this.hasAttribute(Attributes.KEYBOARD_UP_VOLUME_STEP) ? +this.getAttribute(Attributes.KEYBOARD_UP_VOLUME_STEP) : DEFAULT_VOLUME_STEP; detail = Math.min( (this.mediaStore.getState().mediaVolume ?? 1) + step, 1 ); evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_VOLUME_REQUEST, { composed: true, bubbles: true, detail, }); this.dispatchEvent(evt); break; } case 'ArrowDown': { const step = this.hasAttribute(Attributes.KEYBOARD_DOWN_VOLUME_STEP) ? +this.getAttribute(Attributes.KEYBOARD_DOWN_VOLUME_STEP) : DEFAULT_VOLUME_STEP; detail = Math.max( (this.mediaStore.getState().mediaVolume ?? 1) - step, 0 ); evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_VOLUME_REQUEST, { composed: true, bubbles: true, detail, }); this.dispatchEvent(evt); break; } case '<': { const playbackRate = this.mediaStore.getState().mediaPlaybackRate ?? 1; detail = Math.max( playbackRate - DEFAULT_PLAYBACK_RATE_STEP, MIN_PLAYBACK_RATE ).toFixed(2); evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_PLAYBACK_RATE_REQUEST, { composed: true, bubbles: true, detail, }); this.dispatchEvent(evt); break; } case '>': { const playbackRate = this.mediaStore.getState().mediaPlaybackRate ?? 1; detail = Math.min( playbackRate + DEFAULT_PLAYBACK_RATE_STEP, MAX_PLAYBACK_RATE ).toFixed(2); evt = new globalThis.CustomEvent(MediaUIEvents.MEDIA_PLAYBACK_RATE_REQUEST, { composed: true, bubbles: true, detail, }); this.dispatchEvent(evt); break; } case '/': case '?': { // Check if Shift is pressed for Shift + / if (e.shiftKey) { this.#showKeyboardShortcutsDialog(); } break; } case 'p': { eventName = this.mediaStore.getState().mediaIsPip ? MediaUIEvents.MEDIA_EXIT_PIP_REQUEST : MediaUIEvents.MEDIA_ENTER_PIP_REQUEST; evt = new globalThis.CustomEvent(eventName, { composed: true, bubbles: true, }); this.dispatchEvent(evt); break; } default: break; } } #showKeyboardShortcutsDialog() { if (!this.#keyboardShortcutsDialog) { this.#keyboardShortcutsDialog = document.createElement( 'media-keyboard-shortcuts-dialog' ) as MediaKeyboardShortcutsDialog; this.appendChild(this.#keyboardShortcutsDialog); } this.#keyboardShortcutsDialog.open = true; } } const MEDIA_UI_ATTRIBUTE_NAMES = Object.values(MediaUIAttributes); const MEDIA_UI_PROP_NAMES = Object.values(MediaUIProps); const getMediaUIAttributesFrom = (child: HTMLElement): string[] => { let { observedAttributes } = child.constructor as typeof CustomElement; // observedAttributes are only available if the custom element was upgraded. // example: media-gesture-receiver in the shadow DOM requires an upgrade. if (!observedAttributes && child.nodeName?.includes('-')) { globalThis.customElements.upgrade(child); ({ observedAttributes } = child.constructor as typeof CustomElement); } const mediaChromeAttributesList = child ?.getAttribute?.(MediaStateReceiverAttributes.MEDIA_CHROME_ATTRIBUTES) ?.split?.(/\s+/); if (!Array.isArray(observedAttributes || mediaChromeAttributesList)) return []; return (observedAttributes || mediaChromeAttributesList).filter((attrName) => MEDIA_UI_ATTRIBUTE_NAMES.includes(attrName) ); }; const hasMediaUIProps = (mediaStateReceiverCandidate: HTMLElement): boolean => { if ( mediaStateReceiverCandidate.nodeName?.includes('-') && !!globalThis.customElements.get( mediaStateReceiverCandidate.nodeName?.toLowerCase() ) && !( mediaStateReceiverCandidate instanceof globalThis.customElements.get( mediaStateReceiverCandidate.nodeName.toLowerCase() ) ) ) { globalThis.customElements.upgrade(mediaStateReceiverCandidate); } return MEDIA_UI_PROP_NAMES.some( (propName) => propName in mediaStateReceiverCandidate ); }; const isMediaStateReceiver = (child: HTMLElement): boolean => { return hasMediaUIProps(child) || !!getMediaUIAttributesFrom(child).length; }; const serializeTuple = (tuple: any[]): string | undefined => tuple?.join?.(':'); const CustomAttrSerializer: Record string> = { [MediaUIAttributes.MEDIA_SUBTITLES_LIST]: stringifyTextTrackList, [MediaUIAttributes.MEDIA_SUBTITLES_SHOWING]: stringifyTextTrackList, [MediaUIAttributes.MEDIA_SEEKABLE]: serializeTuple, [MediaUIAttributes.MEDIA_BUFFERED]: (tuples: any[][]): string => tuples?.map(serializeTuple).join(' '), [MediaUIAttributes.MEDIA_PREVIEW_COORDS]: (coords: number[]): string => coords?.join(' '), [MediaUIAttributes.MEDIA_RENDITION_LIST]: stringifyRenditionList, [MediaUIAttributes.MEDIA_AUDIO_TRACK_LIST]: stringifyAudioTrackList, }; const setAttr = async ( child: HTMLElement, attrName: string, attrValue: any ): Promise => { // If the node is not connected to the DOM yet wait on macrotask. Fix for: // Uncaught DOMException: Failed to construct 'CustomElement': // The result must not have attributes if (!child.isConnected) { await delay(0); } // NOTE: For "nullish" (null/undefined), can use any setter if (typeof attrValue === 'boolean' || attrValue == null) { return setBooleanAttr(child, attrName, attrValue); } if (typeof attrValue === 'number') { return setNumericAttr(child, attrName, attrValue); } if (typeof attrValue === 'string') { return setStringAttr(child, attrName, attrValue); } // Treat empty arrays as "nothing" values if (Array.isArray(attrValue) && !attrValue.length) { return child.removeAttribute(attrName); } // For "special" values with custom serializers or all other values const val = CustomAttrSerializer[attrName]?.(attrValue) ?? attrValue; return child.setAttribute(attrName, val); }; const isMediaSlotElementDescendant = (el: HTMLElement): boolean => !!el.closest?.('*[slot="media"]'); /** * * @description This function will recursively check for any descendants (including the rootNode) * that are Media State Receivers and invoke `mediaStateReceiverCallback` with any Media State Receiver * found * * @param {HTMLElement} rootNode * @param {function} mediaStateReceiverCallback */ const traverseForMediaStateReceivers = ( rootNode: HTMLElement, mediaStateReceiverCallback: (element: HTMLElement) => void ): void => { // We (currently) don't check if descendants of the `media` (e.g.