media-chrome
Version:
Custom elements (web components) for making audio and video player controls that look great in your website or app.
227 lines (189 loc) • 7.11 kB
text/typescript
import { globalThis } from './utils/server-safe-globals.js';
import {
MediaUIAttributes,
MediaStateReceiverAttributes,
} from './constants.js';
import {
getOrInsertCSSRule,
getStringAttr,
namedNodeMapToObject,
setStringAttr,
} from './utils/element-utils.js';
import MediaController from './media-controller.js';
function getTemplateHTML(_attrs: Record<string, string>) {
return /*html*/ `
<style>
:host {
box-sizing: border-box;
display: var(--media-control-display, var(--media-preview-thumbnail-display, inline-block));
overflow: hidden;
}
img {
display: none;
position: relative;
}
</style>
<img crossorigin loading="eager" decoding="async">
`;
}
/**
*
* @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within).
* @attr {string} mediapreviewimage - (read-only) Set to the timeline preview image URL.
* @attr {string} mediapreviewcoords - (read-only) Set to the active preview image coordinates.
*
* @cssproperty [--media-preview-thumbnail-display = inline-block] - `display` property of display.
* @cssproperty [--media-control-display = inline-block] - `display` property of control.
* @cssproperty [--media-preview-thumbnail-object-fit = contain] - Controls how the thumbnail scales within its container. `contain` (default) maintains aspect ratio, `fill` allows independent width/height scaling.
*/
class MediaPreviewThumbnail extends globalThis.HTMLElement {
static shadowRootOptions = { mode: 'open' as ShadowRootMode };
static getTemplateHTML = getTemplateHTML;
#mediaController: MediaController;
static get observedAttributes() {
return [
MediaStateReceiverAttributes.MEDIA_CONTROLLER,
MediaUIAttributes.MEDIA_PREVIEW_IMAGE,
MediaUIAttributes.MEDIA_PREVIEW_COORDS,
];
}
imgWidth: number;
imgHeight: number;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow((this.constructor as typeof MediaPreviewThumbnail).shadowRootOptions);
const attrs = namedNodeMapToObject(this.attributes);
this.shadowRoot.innerHTML = (this.constructor as typeof MediaPreviewThumbnail).getTemplateHTML(attrs);
}
}
connectedCallback(): void {
const mediaControllerId = this.getAttribute(
MediaStateReceiverAttributes.MEDIA_CONTROLLER
);
if (mediaControllerId) {
this.#mediaController =
// @ts-ignore
this.getRootNode()?.getElementById(mediaControllerId);
this.#mediaController?.associateElement?.(this);
}
}
disconnectedCallback(): void {
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (
[
MediaUIAttributes.MEDIA_PREVIEW_IMAGE,
MediaUIAttributes.MEDIA_PREVIEW_COORDS,
].includes(attrName as any)
) {
this.update();
}
if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
// @ts-ignore
this.#mediaController = this.getRootNode()?.getElementById(newValue);
this.#mediaController?.associateElement?.(this);
}
}
}
/**
* @type {string | undefined} The url of the preview image
*/
get mediaPreviewImage() {
return getStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_IMAGE);
}
set mediaPreviewImage(value) {
setStringAttr(this, MediaUIAttributes.MEDIA_PREVIEW_IMAGE, value);
}
/**
* @type {Array<number> | undefined} Fixed length array [x, y, width, height] or undefined
*/
get mediaPreviewCoords() {
const attrVal = this.getAttribute(MediaUIAttributes.MEDIA_PREVIEW_COORDS);
if (!attrVal) return undefined;
return attrVal.split(/\s+/).map((coord) => +coord);
}
set mediaPreviewCoords(value) {
if (!value) {
this.removeAttribute(MediaUIAttributes.MEDIA_PREVIEW_COORDS);
return;
}
this.setAttribute(MediaUIAttributes.MEDIA_PREVIEW_COORDS, value.join(' '));
}
update(): void {
const coords = this.mediaPreviewCoords;
const previewImage = this.mediaPreviewImage;
if (!(coords && previewImage)) return;
const [x, y, w, h] = coords;
const src = previewImage.split('#')[0];
const computedStyle = getComputedStyle(this);
const { maxWidth, maxHeight, minWidth, minHeight } = computedStyle;
// Check if user wants independent width/height scaling (fill mode)
// Default is 'contain' which preserves aspect ratio
const objectFit = computedStyle.getPropertyValue('--media-preview-thumbnail-object-fit').trim() || 'contain';
let scaleX: number;
let scaleY: number;
if (objectFit === 'fill') {
const maxRatioX = parseInt(maxWidth) / w;
const maxRatioY = parseInt(maxHeight) / h;
const minRatioX = parseInt(minWidth) / w;
const minRatioY = parseInt(minHeight) / h;
scaleX = maxRatioX < 1 ? maxRatioX : Math.max(maxRatioX, minRatioX);
scaleY = maxRatioY < 1 ? maxRatioY : Math.max(maxRatioY, minRatioY);
} else {
const maxRatio = Math.min(parseInt(maxWidth) / w, parseInt(maxHeight) / h);
const minRatio = Math.max(parseInt(minWidth) / w, parseInt(minHeight) / h);
const isScalingDown = maxRatio < 1;
const scale = isScalingDown ? maxRatio : minRatio > 1 ? minRatio : 1;
scaleX = scale;
scaleY = scale;
}
const { style } = getOrInsertCSSRule(this.shadowRoot, ':host');
const imgStyle = getOrInsertCSSRule(this.shadowRoot, 'img').style;
const img = this.shadowRoot.querySelector('img');
// Revert one set of extremum to its initial value on a known scale direction.
const isScalingDown = Math.min(scaleX, scaleY) < 1;
const extremum = isScalingDown ? 'min' : 'max';
style.setProperty(`${extremum}-width`, 'initial', 'important');
style.setProperty(`${extremum}-height`, 'initial', 'important');
style.width = `${w * scaleX}px`;
style.height = `${h * scaleY}px`;
const resize = () => {
imgStyle.width = `${this.imgWidth * scaleX}px`;
imgStyle.height = `${this.imgHeight * scaleY}px`;
imgStyle.display = 'block';
};
if (img.src !== src) {
img.onload = () => {
this.imgWidth = img.naturalWidth;
this.imgHeight = img.naturalHeight;
resize();
img.onload = null;
};
img.src = src;
resize();
}
resize();
imgStyle.transform = `translate(-${x * scaleX}px, -${y * scaleY}px)`;
}
}
if (!globalThis.customElements.get('media-preview-thumbnail')) {
globalThis.customElements.define(
'media-preview-thumbnail',
MediaPreviewThumbnail
);
}
export default MediaPreviewThumbnail;