media-chrome
Version:
Custom elements (web components) for making audio and video player controls that look great in your website or app.
338 lines (285 loc) • 9.64 kB
text/typescript
import { MediaTextDisplay } from './media-text-display.js';
import {
getBooleanAttr,
getNumericAttr,
getOrInsertCSSRule,
setBooleanAttr,
setNumericAttr,
} from './utils/element-utils.js';
import { globalThis } from './utils/server-safe-globals.js';
import { formatAsTimePhrase, formatTime } from './utils/time.js';
import { MediaUIAttributes } from './constants.js';
import { t } from './utils/i18n.js';
export const Attributes = {
REMAINING: 'remaining',
SHOW_DURATION: 'showduration',
NO_TOGGLE: 'notoggle',
};
const CombinedAttributes = [
...Object.values(Attributes),
MediaUIAttributes.MEDIA_CURRENT_TIME,
MediaUIAttributes.MEDIA_DURATION,
MediaUIAttributes.MEDIA_SEEKABLE,
];
// Todo: Use data locals: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString
const ButtonPressedKeys = ['Enter', ' '];
const DEFAULT_TIMES_SEP = ' / ';
const formatTimesLabel = (
el: MediaTimeDisplay,
{ timesSep = DEFAULT_TIMES_SEP } = {}
): string => {
const currentTime = el.mediaCurrentTime ?? 0;
const [, seekableEnd] = el.mediaSeekable ?? [];
let endTime = 0;
if (Number.isFinite(el.mediaDuration)) {
endTime = el.mediaDuration;
} else if (Number.isFinite(seekableEnd)) {
endTime = seekableEnd;
}
const timeLabel = el.remaining
? formatTime(0 - (endTime - currentTime))
: formatTime(currentTime);
if (!el.showDuration) return timeLabel;
return `${timeLabel}${timesSep}${formatTime(endTime)}`;
};
const updateAriaDescription = (el: MediaTimeDisplay): void => {
const currentTime = el.mediaCurrentTime;
const [, seekableEnd] = el.mediaSeekable ?? [];
let endTime = null;
if (Number.isFinite(el.mediaDuration)) {
endTime = el.mediaDuration;
} else if (Number.isFinite(seekableEnd)) {
endTime = seekableEnd;
}
if (currentTime == null || endTime === null) {
el.setAttribute('aria-description', t('video not loaded, unknown time.'));
return;
}
const currentTimePhrase = el.remaining
? formatAsTimePhrase(0 - (endTime - currentTime))
: formatAsTimePhrase(currentTime);
if (!el.showDuration) {
el.setAttribute('aria-description', currentTimePhrase);
return;
}
const totalTimePhrase = formatAsTimePhrase(endTime);
const fullPhrase = t('{currentTime} of {totalTime}', {
currentTime: currentTimePhrase,
totalTime: totalTimePhrase,
});
el.setAttribute('aria-description', fullPhrase);
};
function getSlotTemplateHTML(_attrs: Record<string, string>, props: Record<string, any>) {
return /*html*/ `
<slot>${formatTimesLabel(props as MediaTimeDisplay)}</slot>
`;
}
const updateAriaLabel = (el: MediaTimeDisplay): void => {
el.setAttribute('aria-label', t('playback time'));
};
/**
* @attr {boolean} remaining - Toggle on to show the remaining time instead of elapsed time.
* @attr {boolean} showduration - Toggle on to show the duration.
* @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable.
* @attr {boolean} notoggle - Set this to disable click or tap behavior that toggles between remaining and current time.
* @attr {string} mediacurrenttime - (read-only) Set to the current media time.
* @attr {string} mediaduration - (read-only) Set to the media duration.
* @attr {string} mediaseekable - (read-only) Set to the seekable time ranges.
*
* @cssproperty [--media-time-display-display = inline-flex] - `display` property of display.
* @cssproperty --media-control-hover-background - `background` of control hover state.
*/
class MediaTimeDisplay extends MediaTextDisplay {
static getSlotTemplateHTML = getSlotTemplateHTML;
#slot: HTMLSlotElement;
#keyUpHandler: ((evt: KeyboardEvent) => void) | null = null;
#keyDownHandler = (evt: KeyboardEvent) => {
const { metaKey, altKey, key } = evt;
if (metaKey || altKey || !ButtonPressedKeys.includes(key)) {
this.removeEventListener('keyup', this.#keyUpHandler);
return;
}
this.addEventListener('keyup', this.#keyUpHandler);
}
static get observedAttributes(): string[] {
return [...super.observedAttributes, ...CombinedAttributes, 'disabled'];
}
constructor() {
super();
this.#slot = this.shadowRoot.querySelector('slot');
this.#slot.innerHTML = `${formatTimesLabel(this)}`;
}
connectedCallback(): void {
const { style } = getOrInsertCSSRule(
this.shadowRoot,
':host(:hover:not([notoggle]))'
);
style.setProperty('cursor', 'var(--media-cursor, pointer)');
style.setProperty(
'background',
'var(--media-control-hover-background, rgba(50 50 70 / .7))'
);
this.setAttribute('aria-label', t('playback time'));
this.#makeInteractive();
super.connectedCallback();
}
#setupEventListeners(): void {
if (this.#keyUpHandler) {
return;
}
this.#keyUpHandler = (evt: KeyboardEvent) => {
const { key } = evt;
if (!ButtonPressedKeys.includes(key)) {
this.removeEventListener('keyup', this.#keyUpHandler);
return;
}
this.toggleTimeDisplay();
};
this.addEventListener('keydown', this.#keyDownHandler);
this.addEventListener('click', this.toggleTimeDisplay);
}
#removeEventListeners(): void {
if (this.#keyUpHandler) {
this.removeEventListener('keyup', this.#keyUpHandler);
this.removeEventListener('keydown', this.#keyDownHandler);
this.removeEventListener('click', this.toggleTimeDisplay);
this.#keyUpHandler = null;
}
}
// Makes element clickable and focusable only when not disabled and noToggle is not present
#makeInteractive(): void {
if (!this.noToggle && !this.hasAttribute('disabled')) {
this.setAttribute('role', 'button');
this.enable();
this.#setupEventListeners();
}
}
// Removes interactivity from the element, making it neither clickable nor focusable
#makeNonInteractive(): void {
this.removeAttribute('role');
this.disable();
this.#removeEventListeners();
}
toggleTimeDisplay(): void {
if (this.noToggle) {
return;
}
if (this.hasAttribute('remaining')) {
this.removeAttribute('remaining');
} else {
this.setAttribute('remaining', '');
}
}
disconnectedCallback(): void {
this.disable();
this.#removeEventListeners();
super.disconnectedCallback();
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
updateAriaLabel(this);
if (CombinedAttributes.includes(attrName)) {
this.update();
} else if (attrName === 'disabled' && newValue !== oldValue) {
if (newValue == null) {
this.#makeInteractive();
} else {
this.#makeNonInteractive();
}
} else if (attrName === Attributes.NO_TOGGLE && newValue !== oldValue) {
if (this.noToggle) {
this.#makeNonInteractive();
} else {
this.#makeInteractive();
}
}
super.attributeChangedCallback(attrName, oldValue, newValue);
}
enable(): void {
if (!this.noToggle) {
this.tabIndex = 0;
}
}
disable(): void {
this.tabIndex = -1;
}
// Own props
/**
* Whether to show the remaining time
*/
get remaining(): boolean {
return getBooleanAttr(this, Attributes.REMAINING);
}
set remaining(show: boolean) {
setBooleanAttr(this, Attributes.REMAINING, show);
}
/**
* Whether to show the duration
*/
get showDuration(): boolean {
return getBooleanAttr(this, Attributes.SHOW_DURATION);
}
set showDuration(show: boolean) {
setBooleanAttr(this, Attributes.SHOW_DURATION, show);
}
/**
* Disable the default behavior that toggles between current and remaining time
*/
get noToggle(): boolean {
return getBooleanAttr(this, Attributes.NO_TOGGLE);
}
set noToggle(noToggle: boolean) {
setBooleanAttr(this, Attributes.NO_TOGGLE, noToggle);
}
// Props derived from media UI attributes
/**
* Get the duration
*/
get mediaDuration(): number {
return getNumericAttr(this, MediaUIAttributes.MEDIA_DURATION);
}
set mediaDuration(time: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_DURATION, time);
}
/**
* The current time in seconds
*/
get mediaCurrentTime(): number {
return getNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME);
}
set mediaCurrentTime(time: number) {
setNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME, time);
}
/**
* Range of values that can be seeked to.
* An array of two numbers [start, end]
*/
get mediaSeekable(): [number, number] {
const seekable = this.getAttribute(MediaUIAttributes.MEDIA_SEEKABLE);
if (!seekable) return undefined;
// Only currently supports a single, contiguous seekable range (CJP)
return seekable.split(':').map((time) => +time) as [number, number];
}
set mediaSeekable(range: [number, number]) {
if (range == null) {
this.removeAttribute(MediaUIAttributes.MEDIA_SEEKABLE);
return;
}
this.setAttribute(MediaUIAttributes.MEDIA_SEEKABLE, range.join(':'));
}
update(): void {
const timesLabel = formatTimesLabel(this);
updateAriaDescription(this);
// Only update if it changed, timeupdate events are called a few times per second.
if (timesLabel !== this.#slot.innerHTML) {
this.#slot.innerHTML = timesLabel;
}
}
}
if (!globalThis.customElements.get('media-time-display')) {
globalThis.customElements.define('media-time-display', MediaTimeDisplay);
}
export default MediaTimeDisplay;