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, props: Record) { return /*html*/ ` ${formatTimesLabel(props as MediaTimeDisplay)} `; } 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;