media-chrome
Version:
Custom elements (web components) for making audio and video player controls that look great in your website or app.
1,226 lines (1,093 loc) • 41 kB
text/typescript
/*
The <media-chrome> 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<HTMLElement, () => 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 <media-controller/> 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, (value: any) => 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<void> => {
// 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. <video/>) are Media State Receivers
// See also: `propagateMediaState`
if (isMediaSlotElementDescendant(rootNode)) {
return;
}
const traverseForMediaStateReceiversSync = (
rootNode: HTMLElement,
mediaStateReceiverCallback: (element: HTMLElement) => void
): void => {
// The rootNode is itself a Media State Receiver
if (isMediaStateReceiver(rootNode)) {
mediaStateReceiverCallback(rootNode);
}
const { children = [] } = rootNode ?? {};
const shadowChildren = rootNode?.shadowRoot?.children ?? [];
const allChildren = [...children, ...shadowChildren];
// Traverse all children (including shadowRoot children) to see if they are/have Media State Receivers
allChildren.forEach((child) =>
traverseForMediaStateReceivers(
child as HTMLElement,
mediaStateReceiverCallback
)
);
};
// Custom Elements (and *only* Custom Elements) must have a hyphen ("-") in their name. So, if the rootNode is
// a custom element (aka has a hyphen in its name), wait until it's defined before attempting traversal to determine
// whether or not it or its descendants are Media State Receivers.
// IMPORTANT NOTE: We're intentionally *always* waiting for the `whenDefined()` Promise to resolve here
// (instead of using `globalThis.customElements.get(name)` to check if a custom element is already defined/registered)
// because we encountered some reliability issues with the custom element instances not being fully "ready", even if/when
// they are available in the registry via `globalThis.customElements.get(name)`.
const name = rootNode?.nodeName.toLowerCase();
if (name.includes('-') && !isMediaStateReceiver(rootNode)) {
globalThis.customElements.whenDefined(name).then(() => {
// Try/traverse again once the custom element is defined
traverseForMediaStateReceiversSync(rootNode, mediaStateReceiverCallback);
});
return;
}
traverseForMediaStateReceiversSync(rootNode, mediaStateReceiverCallback);
};
const propagateMediaState = (
els: HTMLElement[],
stateName: string,
val: any
): void => {
els.forEach((el) => {
if (stateName in el) {
el[stateName] = val;
return;
}
const relevantAttrs = getMediaUIAttributesFrom(el);
const attrName = stateName.toLowerCase();
if (!relevantAttrs.includes(attrName)) return;
setAttr(el, attrName, val);
});
};
/**
*
* @description This function will monitor the rootNode for any Media State Receiver descendants
* that are already present, added, or removed, invoking the relevant callback function for each
* case.
*
* @param {HTMLElement} rootNode
* @param {function} registerMediaStateReceiver
* @param {function} unregisterMediaStateReceiver
* @returns An unsubscribe method, used to stop monitoring descendants of rootNode and to unregister its descendants
*
*/
const monitorForMediaStateReceivers = (
rootNode: HTMLElement,
registerMediaStateReceiver: (el: HTMLElement) => void,
unregisterMediaStateReceiver: (el: HTMLElement) => void
): (() => void) => {
// First traverse the tree to register any current Media State Receivers
traverseForMediaStateReceivers(rootNode, registerMediaStateReceiver);
// Monitor for any event-based requests from descendants to register/unregister as a Media State Receiver
const registerMediaStateReceiverHandler = (evt: Event) => {
const el = evt?.composedPath()[0] ?? evt.target;
registerMediaStateReceiver(el as HTMLElement);
};
const unregisterMediaStateReceiverHandler = (evt: Event) => {
const el = evt?.composedPath()[0] ?? evt.target;
unregisterMediaStateReceiver(el as HTMLElement);
};
rootNode.addEventListener(
MediaUIEvents.REGISTER_MEDIA_STATE_RECEIVER,
registerMediaStateReceiverHandler
);
rootNode.addEventListener(
MediaUIEvents.UNREGISTER_MEDIA_STATE_RECEIVER,
unregisterMediaStateReceiverHandler
);
// Observe any changes to the DOM for any descendants that are identifiable as Media State Receivers
// and register or unregister them, depending on the change that occurred.
const mutationCallback = (mutationsList: MutationRecord[]) => {
mutationsList.forEach((mutationRecord) => {
const {
addedNodes = [],
removedNodes = [],
type,
target,
attributeName,
} = mutationRecord;
if (type === 'childList') {
// For each added node, register any Media State Receiver descendants (including itself)
Array.prototype.forEach.call(addedNodes, (node) =>
traverseForMediaStateReceivers(
node as HTMLElement,
registerMediaStateReceiver
)
);
// For each removed node, unregister any Media State Receiver descendants (including itself)
Array.prototype.forEach.call(removedNodes, (node) =>
traverseForMediaStateReceivers(
node as HTMLElement,
unregisterMediaStateReceiver
)
);
} else if (
type === 'attributes' &&
attributeName === MediaStateReceiverAttributes.MEDIA_CHROME_ATTRIBUTES
) {
if (isMediaStateReceiver(target as HTMLElement)) {
// Changed from a "non-Media State Receiver" to a Media State Receiver: register it.
registerMediaStateReceiver(target as HTMLElement);
} else {
// Changed from a Media State Receiver to a "non-Media State Receiver": unregister it.
unregisterMediaStateReceiver(target as HTMLElement);
}
}
});
};
// Storing prevSlotted elements so we can cleanup if slotted elements change over time.
let prevSlotted: HTMLElement[] = [];
const slotChangeHandler = (event: Event) => {
const slotEl = event.target as HTMLSlotElement;
if (slotEl.name === 'media') return;
prevSlotted.forEach((node) =>
traverseForMediaStateReceivers(node, unregisterMediaStateReceiver)
);
prevSlotted = [
...slotEl.assignedElements({ flatten: true }),
] as HTMLElement[];
prevSlotted.forEach((node) =>
traverseForMediaStateReceivers(node, registerMediaStateReceiver)
);
};
rootNode.addEventListener('slotchange', slotChangeHandler);
const observer = new MutationObserver(mutationCallback);
observer.observe(rootNode, {
childList: true,
attributes: true,
subtree: true,
});
const unsubscribe = () => {
// Unregister any Media State Receiver descendants (including ourselves)
traverseForMediaStateReceivers(rootNode, unregisterMediaStateReceiver);
// Make sure we remove the slotchange event listener
rootNode.removeEventListener('slotchange', slotChangeHandler);
// Stop observing for Media State Receivers
observer.disconnect();
// Stop listening for Media State Receiver events.
rootNode.removeEventListener(
MediaUIEvents.REGISTER_MEDIA_STATE_RECEIVER,
registerMediaStateReceiverHandler
);
rootNode.removeEventListener(
MediaUIEvents.UNREGISTER_MEDIA_STATE_RECEIVER,
unregisterMediaStateReceiverHandler
);
};
return unsubscribe;
};
if (!globalThis.customElements.get('media-controller')) {
globalThis.customElements.define('media-controller', MediaController);
}
export { MediaController };
export default MediaController;