import { globalThis } from './utils/server-safe-globals.js'; import { MediaChromeRange } from './media-chrome-range.js'; import './media-preview-thumbnail.js'; import './media-preview-time-display.js'; import './media-preview-chapter-display.js'; import { MediaUIEvents, MediaUIAttributes } from './constants.js'; import { isValidNumber } from './utils/utils.js'; import { formatAsTimePhrase } from './utils/time.js'; import { isElementVisible } from './utils/element-utils.js'; import { RangeAnimation } from './utils/range-animation.js'; import { getOrInsertCSSRule, containsComposedNode, closestComposedNode, getBooleanAttr, setBooleanAttr, getNumericAttr, setNumericAttr, getStringAttr, setStringAttr, } from './utils/element-utils.js'; import { t } from './utils/i18n.js'; import MediaPreviewThumbnail from './media-preview-thumbnail.js'; type Rects = { box: { width: number; min: number; max: number }; range?: DOMRect; bounds?: DOMRect; }; const updateAriaValueText = (el: any): void => { const range = el.range; const currentTimePhrase = formatAsTimePhrase(+calcTimeFromRangeValue(el)); const totalTimePhrase = formatAsTimePhrase(+el.mediaSeekableEnd); const fullPhrase = !(currentTimePhrase && totalTimePhrase) ? t('video not loaded, unknown time.') : t('{currentTime} of {totalTime}', { currentTime: currentTimePhrase, totalTime: totalTimePhrase, }); range.setAttribute('aria-valuetext', fullPhrase); }; function getContainerTemplateHTML(_attrs: Record) { return /*html*/ `
${ /* Example: add the current time w/ arrow to the playhead
*/ '' }
`; } const calcRangeValueFromTime = ( el: any, time: number = el.mediaCurrentTime ): number => { const startTime = Number.isFinite(el.mediaSeekableStart) ? el.mediaSeekableStart : 0; // Prefer `mediaDuration` when available and finite. const endTime = Number.isFinite(el.mediaDuration) ? el.mediaDuration : el.mediaSeekableEnd; if (Number.isNaN(endTime)) return 0; const value = (time - startTime) / (endTime - startTime); return Math.max(0, Math.min(value, 1)); }; const calcTimeFromRangeValue = ( el: any, value: number = el.range.valueAsNumber ): number => { const startTime = Number.isFinite(el.mediaSeekableStart) ? el.mediaSeekableStart : 0; // Prefer `mediaDuration` when available and finite. const endTime = Number.isFinite(el.mediaDuration) ? el.mediaDuration : el.mediaSeekableEnd; if (Number.isNaN(endTime)) return 0; return value * (endTime - startTime) + startTime; }; /** * @slot preview - An element that slides along the timeline to the position of the pointer hovering. * @slot preview-arrow - An arrow element that slides along the timeline to the position of the pointer hovering. * @slot current - An element that slides along the timeline to the position of the current time. * * @attr {string} mediabuffered - (read-only) Set to the buffered time ranges. * @attr {string} mediaplaybackrate - (read-only) Set to the media playback rate. * @attr {string} mediaduration - (read-only) Set to the media duration. * @attr {string} mediaseekable - (read-only) Set to the seekable time ranges. * @attr {boolean} mediapaused - (read-only) Present if the media is paused. * @attr {boolean} medialoading - (read-only) Present if the media is loading. * @attr {string} mediacurrenttime - (read-only) Set to the current media time. * @attr {string} mediapreviewimage - (read-only) Set to the timeline preview image URL. * @attr {string} mediapreviewtime - (read-only) Set to the timeline preview time. * * @csspart buffered - A CSS part that selects the buffered bar element. * @csspart box - A CSS part that selects both the preview and current box elements. * @csspart preview-box - A CSS part that selects the preview box element. * @csspart current-box - A CSS part that selects the current box element. * @csspart arrow - A CSS part that selects the arrow element. * * @cssproperty [--media-time-range-display = inline-block] - `display` property of range. * @cssproperty --media-time-range-buffered-color - `background` color of buffered range. * * @cssproperty --media-preview-transition-property - `transition-property` of range hover preview. * @cssproperty --media-preview-transition-duration-out - `transition-duration` out of range hover preview. * @cssproperty --media-preview-transition-delay-out - `transition-delay` out of range hover preview. * @cssproperty --media-preview-transition-duration-in - `transition-duration` in of range hover preview. * @cssproperty --media-preview-transition-delay-in - `transition-delay` in of range hover preview. * * @cssproperty --media-preview-thumbnail-background - `background` of range preview thumbnail. * @cssproperty --media-preview-thumbnail-box-shadow - `box-shadow` of range preview thumbnail. * @cssproperty --media-preview-thumbnail-max-width - `max-width` of range preview thumbnail. * @cssproperty --media-preview-thumbnail-max-height - `max-height` of range preview thumbnail. * @cssproperty --media-preview-thumbnail-min-width - `min-width` of range preview thumbnail. * @cssproperty --media-preview-thumbnail-min-height - `min-height` of range preview thumbnail. * @cssproperty --media-preview-thumbnail-object-fit - Controls scaling behavior: `contain` (default, maintains aspect ratio) or `fill` (allows independent width/height scaling). * @cssproperty --media-preview-thumbnail-border-radius - `border-radius` of range preview thumbnail. * @cssproperty --media-preview-thumbnail-border - `border` of range preview thumbnail. * * @cssproperty --media-preview-chapter-background - `background` of range preview chapter display. * @cssproperty --media-preview-chapter-border-radius - `border-radius` of range preview chapter display. * @cssproperty --media-preview-chapter-padding - `padding` of range preview chapter display. * @cssproperty --media-preview-chapter-margin - `margin` of range preview chapter display. * @cssproperty --media-preview-chapter-text-shadow - `text-shadow` of range preview chapter display. * * @cssproperty --media-preview-background - `background` of range preview elements. * @cssproperty --media-preview-border-radius - `border-radius` of range preview elements. * * @cssproperty --media-preview-time-background - `background` of range preview time display. * @cssproperty --media-preview-time-border-radius - `border-radius` of range preview time display. * @cssproperty --media-preview-time-padding - `padding` of range preview time display. * @cssproperty --media-preview-time-margin - `margin` of range preview time display. * @cssproperty --media-preview-time-text-shadow - `text-shadow` of range preview time display. * * @cssproperty --media-box-display - `display` of range box. * @cssproperty --media-box-margin - `margin` of range box. * @cssproperty --media-box-padding-left - `padding-left` of range box. * @cssproperty --media-box-padding-right - `padding-right` of range box. * @cssproperty --media-box-border-radius - `border-radius` of range box. * * @cssproperty --media-preview-box-display - `display` of range preview box. * @cssproperty --media-preview-box-margin - `margin` of range preview box. * * @cssproperty --media-current-box-display - `display` of range current box. * @cssproperty --media-current-box-margin - `margin` of range current box. * * @cssproperty --media-box-arrow-display - `display` of range box arrow. * @cssproperty --media-box-arrow-background - `border-top-color` of range box arrow. * @cssproperty --media-box-arrow-border-width - `border-width` of range box arrow. * @cssproperty --media-box-arrow-height - `height` of range box arrow. * @cssproperty --media-box-arrow-width - `width` of range box arrow. * @cssproperty --media-box-arrow-offset - `translateX` offset of range box arrow. * * @cssproperty --media-cursor - `cursor` property. * @cssproperty --media-focus-box-shadow - `box-shadow` of focused control. */ class MediaTimeRange extends MediaChromeRange { static shadowRootOptions = { mode: 'open' as ShadowRootMode }; static getContainerTemplateHTML = getContainerTemplateHTML; static get observedAttributes(): string[] { return [ ...super.observedAttributes, MediaUIAttributes.MEDIA_PAUSED, MediaUIAttributes.MEDIA_DURATION, MediaUIAttributes.MEDIA_SEEKABLE, MediaUIAttributes.MEDIA_CURRENT_TIME, MediaUIAttributes.MEDIA_PREVIEW_IMAGE, MediaUIAttributes.MEDIA_PREVIEW_TIME, MediaUIAttributes.MEDIA_PREVIEW_CHAPTER, MediaUIAttributes.MEDIA_BUFFERED, MediaUIAttributes.MEDIA_PLAYBACK_RATE, MediaUIAttributes.MEDIA_LOADING, MediaUIAttributes.MEDIA_ENDED, ]; } #rootNode: Node | null = null; #animation: RangeAnimation; #boxes; #previewTime: number; #previewBox: HTMLElement; #currentBox: HTMLElement; #boxPaddingLeft: number; #boxPaddingRight: number; #mediaChaptersCues; #isPointerDown: boolean; constructor() { super(); const track = this.shadowRoot.querySelector('#track'); track.insertAdjacentHTML( 'afterbegin', '
' ); this.#boxes = this.shadowRoot.querySelectorAll('[part~="box"]'); this.#previewBox = this.shadowRoot.querySelector('[part~="preview-box"]'); this.#currentBox = this.shadowRoot.querySelector('[part~="current-box"]'); const computedStyle = getComputedStyle(this); this.#boxPaddingLeft = parseInt( computedStyle.getPropertyValue('--media-box-padding-left') ); this.#boxPaddingRight = parseInt( computedStyle.getPropertyValue('--media-box-padding-right') ); this.#animation = new RangeAnimation(this.range, this.#updateRange, 60); } connectedCallback(): void { super.connectedCallback(); this.range.setAttribute('aria-label', t('seek')); this.#toggleRangeAnimation(); // NOTE: Adding an event listener to an ancestor here. this.#rootNode = this.getRootNode(); this.#rootNode?.addEventListener('transitionstart', this); } disconnectedCallback(): void { super.disconnectedCallback(); this.#animation.stop(); this.#rootNode?.removeEventListener('transitionstart', this); this.#rootNode = null; } attributeChangedCallback( attrName: string, oldValue: string | null, newValue: string | null ): void { super.attributeChangedCallback(attrName, oldValue, newValue); if (oldValue == newValue) return; if ( attrName === MediaUIAttributes.MEDIA_CURRENT_TIME || attrName === MediaUIAttributes.MEDIA_PAUSED || attrName === MediaUIAttributes.MEDIA_ENDED || attrName === MediaUIAttributes.MEDIA_LOADING || attrName === MediaUIAttributes.MEDIA_DURATION || attrName === MediaUIAttributes.MEDIA_SEEKABLE ) { this.#animation.update({ start: calcRangeValueFromTime(this), duration: this.mediaSeekableEnd - this.mediaSeekableStart, playbackRate: this.mediaPlaybackRate, }); this.#toggleRangeAnimation(); updateAriaValueText(this); } else if (attrName === MediaUIAttributes.MEDIA_BUFFERED) { this.updateBufferedBar(); } if ( attrName === MediaUIAttributes.MEDIA_DURATION || attrName === MediaUIAttributes.MEDIA_SEEKABLE ) { this.mediaChaptersCues = this.#mediaChaptersCues; this.updateBar(); } } #toggleRangeAnimation = (): void => { if (this.#shouldRangeAnimate()) { this.#animation.start(); } else { this.#animation.stop(); } } #shouldRangeAnimate(): boolean { return ( this.isConnected && !this.mediaPaused && !this.mediaLoading && !this.mediaEnded && this.mediaSeekableEnd > 0 && isElementVisible(this) ); } #updateRange = (value: number): void => { if (this.dragging) return; if (isValidNumber(value)) { this.range.valueAsNumber = value; } if (!this.#isPointerDown) { this.updateBar(); } }; get mediaChaptersCues(): any[] { return this.#mediaChaptersCues; } set mediaChaptersCues(value: any[]) { this.#mediaChaptersCues = value; this.updateSegments( this.#mediaChaptersCues?.map((c) => ({ start: calcRangeValueFromTime(this, c.startTime), end: calcRangeValueFromTime(this, c.endTime), })) ); } /** * Is the media paused */ get mediaPaused(): boolean { return getBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED); } set mediaPaused(value: boolean) { setBooleanAttr(this, MediaUIAttributes.MEDIA_PAUSED, value); } /** * Is the media loading */ get mediaLoading(): boolean { return getBooleanAttr(this, MediaUIAttributes.MEDIA_LOADING); } set mediaLoading(value: boolean) { setBooleanAttr(this, MediaUIAttributes.MEDIA_LOADING, value); } /** * */ get mediaDuration(): number | undefined { return getNumericAttr(this, MediaUIAttributes.MEDIA_DURATION); } set mediaDuration(value: number | undefined) { setNumericAttr(this, MediaUIAttributes.MEDIA_DURATION, value); } /** * */ get mediaCurrentTime(): number | undefined { return getNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME); } set mediaCurrentTime(value: number | undefined) { setNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME, value); } /** * */ get mediaPlaybackRate(): number { return getNumericAttr(this, MediaUIAttributes.MEDIA_PLAYBACK_RATE, 1); } set mediaPlaybackRate(value: number) { setNumericAttr(this, MediaUIAttributes.MEDIA_PLAYBACK_RATE, value); } /** * An array of ranges, each range being an array of two numbers. * e.g. [[1, 2], [3, 4]] */ get mediaBuffered(): number[][] { const buffered = this.getAttribute(MediaUIAttributes.MEDIA_BUFFERED); if (!buffered) return []; return buffered .split(' ') .map((timePair) => timePair.split(':').map((timeStr) => +timeStr)); } set mediaBuffered(list: number[][]) { if (!list) { this.removeAttribute(MediaUIAttributes.MEDIA_BUFFERED); return; } const strVal = list.map((tuple) => tuple.join(':')).join(' '); this.setAttribute(MediaUIAttributes.MEDIA_BUFFERED, strVal); } /** * Range of values that can be seeked to * An array of two numbers [start, end] */ get mediaSeekable(): number[] | undefined { 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); } set mediaSeekable(range: number[] | undefined) { if (range == null) { this.removeAttribute(MediaUIAttributes.MEDIA_SEEKABLE); return; } this.setAttribute(MediaUIAttributes.MEDIA_SEEKABLE, range.join(':')); } /** * */ get mediaSeekableEnd(): number | undefined { const [, end = this.mediaDuration] = this.mediaSeekable ?? []; return end; } get mediaSeekableStart(): number { const [start = 0] = this.mediaSeekable ?? []; return start; } /** * The url of the preview image */ get mediaPreviewImage(): string | undefined { return getStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_IMAGE); } set mediaPreviewImage(value: string | undefined) { setStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_IMAGE, value); } /** * */ get mediaPreviewTime(): number | undefined { return getNumericAttr(this, MediaUIAttributes.MEDIA_PREVIEW_TIME); } set mediaPreviewTime(value: number | undefined) { setNumericAttr(this, MediaUIAttributes.MEDIA_PREVIEW_TIME, value); } /** * */ get mediaEnded(): boolean | undefined { return getBooleanAttr(this, MediaUIAttributes.MEDIA_ENDED); } set mediaEnded(value: boolean | undefined) { setBooleanAttr(this, MediaUIAttributes.MEDIA_ENDED, value); } /* Add a buffered progress bar */ updateBar(): void { super.updateBar(); this.updateBufferedBar(); this.updateCurrentBox(); } updateBufferedBar(): void { const buffered = this.mediaBuffered; if (!buffered.length) { return; } // Find the buffered range that "contains" the current time and get its end. // If none, just assume the start of the media timeline for // visualization purposes. let relativeBufferedEnd; if (!this.mediaEnded) { const currentTime = this.mediaCurrentTime; const [, bufferedEnd = this.mediaSeekableStart] = buffered.find( ([start, end]) => start <= currentTime && currentTime <= end ) ?? []; relativeBufferedEnd = calcRangeValueFromTime(this, bufferedEnd); } else { // If we've ended, there may be some discrepancies between seekable end, duration, and current time. // In this case, just presume `relativeBufferedEnd` is the maximum possible value for visualization // purposes (CJP.) relativeBufferedEnd = 1; } const { style } = getOrInsertCSSRule(this.shadowRoot, '#buffered'); style.setProperty('width', `${relativeBufferedEnd * 100}%`); } updateCurrentBox(): void { // If there are no elements in the current box no need for expensive style updates. const currentSlot: HTMLSlotElement = this.shadowRoot.querySelector( 'slot[name="current"]' ); if (!currentSlot.assignedElements().length) return; const currentRailRule = getOrInsertCSSRule( this.shadowRoot, '#current-rail' ); const currentBoxRule = getOrInsertCSSRule( this.shadowRoot, '[part~="current-box"]' ); const rects = this.#getElementRects(this.#currentBox); const boxPos = this.#getBoxPosition(rects, this.range.valueAsNumber); const boxShift = this.#getBoxShiftPosition(rects, this.range.valueAsNumber); currentRailRule.style.transform = `translateX(${boxPos})`; currentRailRule.style.setProperty('--_range-width', `${rects.range.width}`); currentBoxRule.style.setProperty('--_box-shift', `${boxShift}`); currentBoxRule.style.setProperty('--_box-width', `${rects.box.width}px`); currentBoxRule.style.setProperty('visibility', 'initial'); } #getElementRects(box: HTMLElement) { // Get the element that enforces the bounds for the time range boxes. const bounds = (this.getAttribute('bounds') ? closestComposedNode(this, `#${this.getAttribute('bounds')}`) : this.parentElement) ?? this; const boundsRect = bounds.getBoundingClientRect(); const rangeRect = this.range.getBoundingClientRect(); // Use offset dimensions to include borders. const width = box.offsetWidth; const min = -(rangeRect.left - boundsRect.left - width / 2); const max = boundsRect.right - rangeRect.left - width / 2; return { box: { width, min, max }, bounds: boundsRect, range: rangeRect, }; } /** * Get the position, max and min for the box in percentage. * It's important this is in percentage so when the player is resized * the box will move accordingly. */ #getBoxPosition(rects: Rects, ratio: number): string { let position = `${ratio * 100}%`; const { width, min, max } = rects.box; if (!width) return position; if (!Number.isNaN(min)) { const pad = `var(--media-box-padding-left)`; const minPos = `calc(1 / var(--_range-width) * 100 * ${min}% + ${pad})`; position = `max(${minPos}, ${position})`; } if (!Number.isNaN(max)) { const pad = `var(--media-box-padding-right)`; const maxPos = `calc(1 / var(--_range-width) * 100 * ${max}% - ${pad})`; position = `min(${position}, ${maxPos})`; } return position; } #getBoxShiftPosition(rects: Rects, ratio: number) { const { width, min, max } = rects.box; const pointerX = ratio * rects.range.width; if (pointerX < min + this.#boxPaddingLeft) { const offset = rects.range.left - rects.bounds.left - this.#boxPaddingLeft; return `${pointerX - width / 2 + offset}px`; } if (pointerX > max - this.#boxPaddingRight) { const offset = rects.bounds.right - rects.range.right - this.#boxPaddingRight; return `${pointerX + width / 2 - offset - rects.range.width}px`; } return 0; } handleEvent(evt: Event | MouseEvent): void { super.handleEvent(evt); switch (evt.type) { case 'input': this.#seekRequest(); break; case 'pointermove': this.#handlePointerMove(evt as MouseEvent); break; case 'pointerup': if (this.#isPointerDown) this.#isPointerDown = false; break; case 'pointerdown': this.#isPointerDown = true; break; case 'pointerleave': this.#previewRequest(null); break; case 'transitionstart': if (containsComposedNode(evt.target as any, this)) { // Wait a tick to be sure the transition has started. Required for Safari. setTimeout(() => this.#toggleRangeAnimation(), 0); } break; } } #handlePointerMove(evt: MouseEvent): void { // @ts-ignore const isOverBoxes = [...this.#boxes].some((b) => evt.composedPath().includes(b) ); if (!this.dragging && (isOverBoxes || !evt.composedPath().includes(this))) { this.#previewRequest(null); return; } const duration = this.mediaSeekableEnd; // If no duration we can't calculate which time to show if (!duration) return; const previewRailRule = getOrInsertCSSRule( this.shadowRoot, '#preview-rail' ); const previewBoxRule = getOrInsertCSSRule( this.shadowRoot, '[part~="preview-box"]' ); const rects = this.#getElementRects(this.#previewBox); let pointerRatio = (evt.clientX - rects.range.left) / rects.range.width; pointerRatio = Math.max(0, Math.min(1, pointerRatio)); const boxPos = this.#getBoxPosition(rects, pointerRatio); const boxShift = this.#getBoxShiftPosition(rects, pointerRatio); previewRailRule.style.transform = `translateX(${boxPos})`; previewRailRule.style.setProperty('--_range-width', `${rects.range.width}`); previewBoxRule.style.setProperty('--_box-shift', `${boxShift}`); previewBoxRule.style.setProperty('--_box-width', `${rects.box.width}px`); // At least require a 1s difference before requesting a new preview thumbnail, // unless it's at the beginning or end of the timeline. const diff = Math.round(this.#previewTime) - Math.round(pointerRatio * duration); if (Math.abs(diff) < 1 && pointerRatio > 0.01 && pointerRatio < 0.99) return; this.#previewTime = pointerRatio * duration; this.#previewRequest(this.#previewTime); } #previewRequest(detail): void { this.dispatchEvent( new globalThis.CustomEvent(MediaUIEvents.MEDIA_PREVIEW_REQUEST, { composed: true, bubbles: true, detail, }) ); } #seekRequest(): void { // Cancel progress bar refreshing when seeking. this.#animation.stop(); const detail = calcTimeFromRangeValue(this); this.dispatchEvent( new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, { composed: true, bubbles: true, detail, }) ); } } if (!globalThis.customElements.get('media-time-range')) { globalThis.customElements.define('media-time-range', MediaTimeRange); } export default MediaTimeRange;