UNPKG

media-chrome

Version:

Custom elements (web components) for making audio and video player controls that look great in your website or app.

944 lines (833 loc) • 33.9 kB
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<string, string>) { return /*html*/ ` <style> :host { --media-box-border-radius: 4px; --media-box-padding-left: 10px; --media-box-padding-right: 10px; --media-preview-border-radius: var(--media-box-border-radius); --media-box-arrow-offset: var(--media-box-border-radius); --_control-background: var(--media-control-background, var(--media-secondary-color, rgb(20 20 30 / .7))); --_preview-background: var(--media-preview-background, var(--_control-background)); ${ /* 1% rail width trick was off in Safari, contain: layout seems to prevent the horizontal overflow as well. */ '' } contain: layout; } #buffered { background: var(--media-time-range-buffered-color, rgb(255 255 255 / .4)); position: absolute; height: 100%; will-change: width; } #preview-rail, #current-rail { width: 100%; position: absolute; left: 0; bottom: 100%; pointer-events: none; will-change: transform; } [part~="box"] { width: min-content; ${ /* absolute position is needed here so the box doesn't overflow the bounds */ '' } position: absolute; bottom: 100%; flex-direction: column; align-items: center; transform: translateX(-50%); } [part~="current-box"] { display: var(--media-current-box-display, var(--media-box-display, flex)); margin: var(--media-current-box-margin, var(--media-box-margin, 0 0 5px)); visibility: hidden; } [part~="preview-box"] { display: var(--media-preview-box-display, var(--media-box-display, flex)); margin: var(--media-preview-box-margin, var(--media-box-margin, 0 0 5px)); transition-property: var(--media-preview-transition-property, visibility, opacity); transition-duration: var(--media-preview-transition-duration-out, .25s); transition-delay: var(--media-preview-transition-delay-out, 0s); visibility: hidden; opacity: 0; } :host(:is([${MediaUIAttributes.MEDIA_PREVIEW_IMAGE}], [${ MediaUIAttributes.MEDIA_PREVIEW_TIME }])[dragging]) [part~="preview-box"] { transition-duration: var(--media-preview-transition-duration-in, .5s); transition-delay: var(--media-preview-transition-delay-in, .25s); visibility: visible; opacity: 1; } @media (hover: hover) { :host(:is([${MediaUIAttributes.MEDIA_PREVIEW_IMAGE}], [${ MediaUIAttributes.MEDIA_PREVIEW_TIME }]):hover) [part~="preview-box"] { transition-duration: var(--media-preview-transition-duration-in, .5s); transition-delay: var(--media-preview-transition-delay-in, .25s); visibility: visible; opacity: 1; } } media-preview-thumbnail, ::slotted(media-preview-thumbnail) { visibility: hidden; ${ /* delay changing these CSS props until the preview box transition is ended */ '' } transition: visibility 0s .25s; transition-delay: calc(var(--media-preview-transition-delay-out, 0s) + var(--media-preview-transition-duration-out, .25s)); background: var(--media-preview-thumbnail-background, var(--_preview-background)); box-shadow: var(--media-preview-thumbnail-box-shadow, 0 0 4px rgb(0 0 0 / .2)); max-width: var(--media-preview-thumbnail-max-width, 180px); max-height: var(--media-preview-thumbnail-max-height, 160px); min-width: var(--media-preview-thumbnail-min-width, 120px); min-height: var(--media-preview-thumbnail-min-height, 80px); border: var(--media-preview-thumbnail-border); border-radius: var(--media-preview-thumbnail-border-radius, var(--media-preview-border-radius) var(--media-preview-border-radius) 0 0); } :host([${ MediaUIAttributes.MEDIA_PREVIEW_IMAGE }][dragging]) media-preview-thumbnail, :host([${ MediaUIAttributes.MEDIA_PREVIEW_IMAGE }][dragging]) ::slotted(media-preview-thumbnail) { transition-delay: var(--media-preview-transition-delay-in, .25s); visibility: visible; } @media (hover: hover) { :host([${ MediaUIAttributes.MEDIA_PREVIEW_IMAGE }]:hover) media-preview-thumbnail, :host([${ MediaUIAttributes.MEDIA_PREVIEW_IMAGE }]:hover) ::slotted(media-preview-thumbnail) { transition-delay: var(--media-preview-transition-delay-in, .25s); visibility: visible; } :host([${MediaUIAttributes.MEDIA_PREVIEW_TIME}]:hover) { --media-time-range-hover-display: block; } } media-preview-chapter-display, ::slotted(media-preview-chapter-display) { font-size: var(--media-font-size, 13px); line-height: 17px; min-width: 0; visibility: hidden; ${ /* delay changing these CSS props until the preview box transition is ended */ '' } transition: min-width 0s, border-radius 0s, margin 0s, padding 0s, visibility 0s; transition-delay: calc(var(--media-preview-transition-delay-out, 0s) + var(--media-preview-transition-duration-out, .25s)); background: var(--media-preview-chapter-background, var(--_preview-background)); border-radius: var(--media-preview-chapter-border-radius, var(--media-preview-border-radius) var(--media-preview-border-radius) var(--media-preview-border-radius) var(--media-preview-border-radius)); padding: var(--media-preview-chapter-padding, 3.5px 9px); margin: var(--media-preview-chapter-margin, 0 0 5px); text-shadow: var(--media-preview-chapter-text-shadow, 0 0 4px rgb(0 0 0 / .75)); } :host([${ MediaUIAttributes.MEDIA_PREVIEW_IMAGE }]) media-preview-chapter-display, :host([${ MediaUIAttributes.MEDIA_PREVIEW_IMAGE }]) ::slotted(media-preview-chapter-display) { transition-delay: var(--media-preview-transition-delay-in, .25s); border-radius: var(--media-preview-chapter-border-radius, 0); padding: var(--media-preview-chapter-padding, 3.5px 9px 0); margin: var(--media-preview-chapter-margin, 0); min-width: 100%; } media-preview-chapter-display[${MediaUIAttributes.MEDIA_PREVIEW_CHAPTER}], ::slotted(media-preview-chapter-display[${ MediaUIAttributes.MEDIA_PREVIEW_CHAPTER }]) { visibility: visible; } media-preview-chapter-display:not([aria-valuetext]), ::slotted(media-preview-chapter-display:not([aria-valuetext])) { display: none; } media-preview-time-display, ::slotted(media-preview-time-display), media-time-display, ::slotted(media-time-display) { font-size: var(--media-font-size, 13px); line-height: 17px; min-width: 0; ${ /* delay changing these CSS props until the preview box transition is ended */ '' } transition: min-width 0s, border-radius 0s; transition-delay: calc(var(--media-preview-transition-delay-out, 0s) + var(--media-preview-transition-duration-out, .25s)); background: var(--media-preview-time-background, var(--_preview-background)); border-radius: var(--media-preview-time-border-radius, var(--media-preview-border-radius) var(--media-preview-border-radius) var(--media-preview-border-radius) var(--media-preview-border-radius)); padding: var(--media-preview-time-padding, 3.5px 9px); margin: var(--media-preview-time-margin, 0); text-shadow: var(--media-preview-time-text-shadow, 0 0 4px rgb(0 0 0 / .75)); transform: translateX(min( max(calc(50% - var(--_box-width) / 2), calc(var(--_box-shift, 0))), calc(var(--_box-width) / 2 - 50%) )); } :host([${ MediaUIAttributes.MEDIA_PREVIEW_IMAGE }]) media-preview-time-display, :host([${ MediaUIAttributes.MEDIA_PREVIEW_IMAGE }]) ::slotted(media-preview-time-display) { transition-delay: var(--media-preview-transition-delay-in, .25s); border-radius: var(--media-preview-time-border-radius, 0 0 var(--media-preview-border-radius) var(--media-preview-border-radius)); min-width: 100%; } :host([${MediaUIAttributes.MEDIA_PREVIEW_TIME}]:hover) { --media-time-range-hover-display: block; } [part~="arrow"], ::slotted([part~="arrow"]) { display: var(--media-box-arrow-display, inline-block); transform: translateX(min( max(calc(50% - var(--_box-width) / 2 + var(--media-box-arrow-offset)), calc(var(--_box-shift, 0))), calc(var(--_box-width) / 2 - 50% - var(--media-box-arrow-offset)) )); ${/* border-color has to come before border-top-color! */ ''} border-color: transparent; border-top-color: var(--media-box-arrow-background, var(--_control-background)); border-width: var(--media-box-arrow-border-width, var(--media-box-arrow-height, 5px) var(--media-box-arrow-width, 6px) 0); border-style: solid; justify-content: center; height: 0; } </style> <div id="preview-rail"> <slot name="preview" part="box preview-box"> <media-preview-thumbnail> <template shadowrootmode="${MediaPreviewThumbnail.shadowRootOptions.mode}"> ${MediaPreviewThumbnail.getTemplateHTML({})} </template> </media-preview-thumbnail> <media-preview-chapter-display></media-preview-chapter-display> <media-preview-time-display></media-preview-time-display> <slot name="preview-arrow"><div part="arrow"></div></slot> </slot> </div> <div id="current-rail"> <slot name="current" part="box current-box"> ${ /* Example: add the current time w/ arrow to the playhead <media-time-display slot="current"></media-time-display> <div part="arrow" slot="current"></div> */ '' } </slot> </div> `; } 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', '<div id="buffered" part="buffered"></div>' ); 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;