import { globalThis, document } from '../../utils/server-safe-globals.js'; import { MediaUIEvents, MediaUIAttributes } from '../../constants.js'; import { setNumericAttr } from '../../utils/element-utils.js'; const template: HTMLTemplateElement = document.createElement('template'); const HANDLE_W = 8; const Z = { 100: 100, 200: 200, 300: 300, }; function lockBetweenZeroAndOne(num: number): number { return Math.max(0, Math.min(1, num)); } template.innerHTML = `
`; /** * */ class MediaClipSelector extends globalThis.HTMLElement { static get observedAttributes() { return [ 'thumbnails', MediaUIAttributes.MEDIA_DURATION, MediaUIAttributes.MEDIA_CURRENT_TIME, ]; } draggingEl: HTMLElement | null; wrapper: HTMLElement; selection: HTMLElement; playhead: HTMLElement; leftTrim: HTMLElement; spacerFirst: HTMLElement; startHandle: HTMLElement; spacerMiddle: HTMLElement; endHandle: HTMLElement; spacerLast: HTMLElement; initialX: number; thumbnailPreview: HTMLElement; _clickHandler: () => void; _dragStart: () => void; _dragEnd: () => void; _drag: () => void; constructor() { super(); if (!this.shadowRoot) { // Set up the Shadow DOM if not using Declarative Shadow DOM. this.attachShadow({ mode: 'open' }); // @ts-ignore this.shadowRoot.appendChild(template.content.cloneNode(true)); } this.draggingEl = null; this.wrapper = this.shadowRoot.querySelector('#selectorContainer'); this.selection = this.shadowRoot.querySelector('#selection'); this.playhead = this.shadowRoot.querySelector('#playhead'); this.leftTrim = this.shadowRoot.querySelector('#leftTrim'); this.spacerFirst = this.shadowRoot.querySelector('#spacerFirst'); this.startHandle = this.shadowRoot.querySelector('#startHandle'); this.spacerMiddle = this.shadowRoot.querySelector('#spacerMiddle'); this.endHandle = this.shadowRoot.querySelector('#endHandle'); this.spacerLast = this.shadowRoot.querySelector('#spacerLast'); this._clickHandler = this.handleClick.bind(this); this._dragStart = this.dragStart.bind(this); this._dragEnd = this.dragEnd.bind(this); this._drag = this.drag.bind(this); this.wrapper.addEventListener('click', this._clickHandler, false); this.wrapper.addEventListener('touchstart', this._dragStart, false); globalThis.window?.addEventListener('touchend', this._dragEnd, false); this.wrapper.addEventListener('touchmove', this._drag, false); this.wrapper.addEventListener('mousedown', this._dragStart, false); globalThis.window?.addEventListener('mouseup', this._dragEnd, false); globalThis.window?.addEventListener('mousemove', this._drag, false); this.enableThumbnails(); } get mediaDuration(): number { return +this.getAttribute(MediaUIAttributes.MEDIA_DURATION); } set mediaDuration(value: number) { setNumericAttr(this, MediaUIAttributes.MEDIA_DURATION, value); } get mediaCurrentTime(): number { return +this.getAttribute(MediaUIAttributes.MEDIA_CURRENT_TIME); } set mediaCurrentTime(value: number) { setNumericAttr(this, MediaUIAttributes.MEDIA_CURRENT_TIME, value); } /* * pass in a mouse event (evt.clientX) * calculates the percentage progress based on the bounding rectang * converts the percentage progress into a duration in seconds */ getPlayheadBasedOnMouseEvent(evt: MouseEvent): number { const duration = this.mediaDuration; if (!duration) return; const mousePercent = lockBetweenZeroAndOne(this.getMousePercent(evt)); return mousePercent * duration; } getXPositionFromMouse(evt: any): number { let clientX; if (['touchstart', 'touchmove'].includes(evt.type)) { clientX = evt.touches[0].clientX; } return clientX || evt.clientX; } getMousePercent(evt: MouseEvent): number { const rangeRect = this.wrapper.getBoundingClientRect(); const mousePercent = (this.getXPositionFromMouse(evt) - rangeRect.left) / rangeRect.width; return lockBetweenZeroAndOne(mousePercent); } dragStart(evt: MouseEvent): void { if (evt.target === this.startHandle) { this.draggingEl = this.startHandle; } if (evt.target === this.endHandle) { this.draggingEl = this.endHandle; } this.initialX = this.getXPositionFromMouse(evt); } dragEnd(): void { this.initialX = null; this.draggingEl = null; } setSelectionWidth(selectionPercent: number, fullTimelineWidth: number): void { let percent = selectionPercent; const minWidthPx = HANDLE_W * 3; const minWidthPercent = lockBetweenZeroAndOne( minWidthPx / fullTimelineWidth ); if (percent < minWidthPercent) { percent = minWidthPercent; } /* * The selection can never be smaller than the width * of 3 handles if (percent === 0) { percent = minWidthPercent; } */ this.selection.style.width = `${percent * 100}%`; } drag(evt: MouseEvent): void { if (!this.draggingEl) { return; } evt.preventDefault(); const rangeRect = this.wrapper.getBoundingClientRect(); const fullTimelineWidth = rangeRect.width; const endXPosition = this.getXPositionFromMouse(evt); const xDelta = endXPosition - this.initialX; const percent = this.getMousePercent(evt); const selectionW = this.selection.getBoundingClientRect().width; /* * When dragging the start handle, change the leftTrim width * and the selection width */ if (this.draggingEl === this.startHandle) { this.initialX = this.getXPositionFromMouse(evt); this.leftTrim.style.width = `${percent * 100}%`; const selectionPercent = lockBetweenZeroAndOne( (selectionW - xDelta) / fullTimelineWidth ); this.setSelectionWidth(selectionPercent, fullTimelineWidth); } /* * When dragging the end handle all we need to do is change * the selection width */ if (this.draggingEl === this.endHandle) { this.initialX = this.getXPositionFromMouse(evt); const selectionPercent = lockBetweenZeroAndOne( (selectionW + xDelta) / fullTimelineWidth ); this.setSelectionWidth(selectionPercent, fullTimelineWidth); } this.dispatchUpdate(); } dispatchUpdate(): void { const updateEvent = new CustomEvent('update', { detail: this.getCurrentClipBounds(), }); this.dispatchEvent(updateEvent); } getCurrentClipBounds(): { startTime: number; endTime: number } { const rangeRect = this.wrapper.getBoundingClientRect(); const leftTrimRect = this.leftTrim.getBoundingClientRect(); const selectionRect = this.selection.getBoundingClientRect(); const percentStart = lockBetweenZeroAndOne( leftTrimRect.width / rangeRect.width ); const percentEnd = lockBetweenZeroAndOne( (leftTrimRect.width + selectionRect.width) / rangeRect.width ); /* * Currently we round to the nearest integer? Might want to change later to round to 1 or 2 decimails? */ return { startTime: Math.round(percentStart * this.mediaDuration), endTime: Math.round(percentEnd * this.mediaDuration), }; } isTimestampInBounds(timestamp: number): boolean { const { startTime, endTime } = this.getCurrentClipBounds(); return startTime <= timestamp && endTime >= timestamp; } handleClick(evt: MouseEvent): void { const mousePercent = this.getMousePercent(evt); const timestampForClick = mousePercent * this.mediaDuration; /* * Clicking outside the selection (out of bounds), does not change the * currentTime of the underlying media, only clicking in bounds does that */ if (this.isTimestampInBounds(timestampForClick)) { this.dispatchEvent( new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, { composed: true, bubbles: true, detail: timestampForClick, }) ); } } mediaCurrentTimeSet(): void { const percentComplete = lockBetweenZeroAndOne( this.mediaCurrentTime / this.mediaDuration ); // const fullW = this.wrapper.getBoundingClientRect().width; // const progressW = percentComplete * fullW; this.playhead.style.left = `${percentComplete * 100}%`; this.playhead.style.display = 'block'; /* * if paused, we don't need to do anything else, but if it is playing * we want to loop within the selection range */ // @ts-ignore if (!this.mediaPaused) { const { startTime, endTime } = this.getCurrentClipBounds(); if ( this.mediaCurrentTime < startTime || this.mediaCurrentTime > endTime ) { this.dispatchEvent( new globalThis.CustomEvent(MediaUIEvents.MEDIA_SEEK_REQUEST, { composed: true, bubbles: true, detail: startTime, }) ); } } } mediaUnsetCallback(media: HTMLVideoElement): void { // @ts-ignore super.mediaUnsetCallback(media); this.wrapper.removeEventListener('touchstart', this._dragStart); this.wrapper.removeEventListener('touchend', this._dragEnd); this.wrapper.removeEventListener('touchmove', this._drag); this.wrapper.removeEventListener('mousedown', this._dragStart); globalThis.window?.removeEventListener('mouseup', this._dragEnd); globalThis.window?.removeEventListener('mousemove', this._drag); } /* * This was copied over from media-time-range, we should have a way of making * this code shared between the two components */ enableThumbnails(): void { /** @type {HTMLElement} */ this.thumbnailPreview = this.shadowRoot.querySelector( 'media-preview-thumbnail' ); /** @type {HTMLElement} */ const thumbnailContainer = this.shadowRoot.querySelector( '#thumbnailContainer' ); thumbnailContainer.classList.add('enabled'); let mouseMoveHandler; const trackMouse = () => { mouseMoveHandler = (evt) => { const duration = this.mediaDuration; // If no duration we can't calculate which time to show if (!duration) return; // Get mouse position percent const rangeRect = this.wrapper.getBoundingClientRect(); const mousePercent = this.getMousePercent(evt); // Get thumbnail center position const leftPadding = rangeRect.left - this.getBoundingClientRect().left; const thumbnailLeft = leftPadding + mousePercent * rangeRect.width; this.thumbnailPreview.style.left = `${thumbnailLeft}px`; this.dispatchEvent( new globalThis.CustomEvent(MediaUIEvents.MEDIA_PREVIEW_REQUEST, { composed: true, bubbles: true, detail: mousePercent * duration, }) ); }; globalThis.window?.addEventListener('mousemove', mouseMoveHandler, false); }; const stopTrackingMouse = () => { globalThis.window?.removeEventListener('mousemove', mouseMoveHandler); }; // Trigger when the mouse moves over the range let rangeEntered = false; const rangeMouseMoveHander = () => { if (!rangeEntered && this.mediaDuration) { rangeEntered = true; this.thumbnailPreview.style.display = 'block'; trackMouse(); const offRangeHandler = (evt) => { if (evt.target != this && !this.contains(evt.target)) { this.thumbnailPreview.style.display = 'none'; globalThis.window?.removeEventListener( 'mousemove', offRangeHandler ); rangeEntered = false; stopTrackingMouse(); } }; globalThis.window?.addEventListener( 'mousemove', offRangeHandler, false ); } if (!this.mediaDuration) { this.thumbnailPreview.style.display = 'none'; } }; this.addEventListener('mousemove', rangeMouseMoveHander, false); } disableThumbnails(): void { const thumbnailContainer = this.shadowRoot.querySelector( '#thumbnailContainer' ); thumbnailContainer.classList.remove('enabled'); } } if (!globalThis.customElements.get('media-clip-selector')) { globalThis.customElements.define('media-clip-selector', MediaClipSelector); } export default MediaClipSelector;