UNPKG

media-show

Version:

Showcase your media player in style!

293 lines (251 loc) 7.25 kB
import { MsBaseElement } from './base-element.js'; import { closestComposedNode, camelCase, kebabCase, serializeTimeRanges, toQuery, getParams, getParam, round, throttle, } from './utils.js'; import { html, css, render } from './html.js'; const styles = () => css` :host { display: block; overflow: hidden; } `; const template = (props) => html` <style> ${styles(props)} </style> <slot></slot> `; const mediaAttrs = [ 'autoplay', 'buffered', 'controls', 'current-time', 'duration', 'ended', 'loop', 'muted', 'paused', 'playback-rate', 'poster', 'preload', 'ready-state', 'src', 'video-height', 'video-width', 'volume', ]; const urlStateAttrs = [ 'autoplay', 'controls', 'current-time', 'loop', 'muted', 'poster', 'preload', 'src', 'volume', ]; const mediaEvents = [ 'emptied', 'durationchange', 'ended', 'error', 'loadstart', 'loadedmetadata', 'resize', 'pause', 'play', 'playing', 'progress', 'ratechange', 'timeupdate', 'volumechange', ]; const getStateAttrs = (el, baseAttrs = []) => { return [...baseAttrs, ...el.templateAttrs, ...el.includeAttrs].filter( (attr) => { return !el.excludeAttrs.includes(attr); } ); }; const cleanups = new WeakMap(); class MediaShow extends MsBaseElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.render(); const { media } = this; // Save the default media properties this.mediaDefaults = this.mediaDefaults ?? getStateAttrs(this, urlStateAttrs).reduce((acc, attr) => { const propName = camelCase(attr); acc[attr] = media[propName]; return acc; }, {}); let oldSrc = media.src; // Get the attribute names that need changing based on state saved in the browser URL. const changeAttrs = getStateAttrs(this, urlStateAttrs).filter((attr) => { return ( getParam(attr) != null && getParam(attr) !== String(this.mediaDefaults[attr]) ); }); // Update the media state from the URL params. changeAttrs.forEach((attr) => { const propName = camelCase(attr); media[propName] = getParam(attr); }); // If the src was updated remember the attributes that need to be cleaned up on a new src change. if (oldSrc !== media.src) { cleanups.set(media, (ignoreAttrs) => { changeAttrs .filter((attr) => !ignoreAttrs.includes(attr)) .forEach((name) => media.removeAttribute(name)); }); } mediaEvents.forEach((event) => { media.addEventListener(event, updateMediaState); }); this.addEventListener('ms-change', (event) => { let oldSrc = media.src; const changeAttrs = Object.keys(event.detail); changeAttrs.forEach((attr) => { const propName = camelCase(attr); media[propName] = event.detail[attr]; }); if (oldSrc !== media.src) { if (cleanups.get(media)) cleanups.get(media)(changeAttrs); cleanups.set(media, (ignoreAttrs) => { changeAttrs .filter((attr) => !ignoreAttrs.includes(attr)) .forEach((name) => media.removeAttribute(name)); }); } updateMediaState({ target: this }); }); } connectedCallback() { updateMediaState({ target: this }); } get templateAttrs() { const template = this.querySelector('template')?.content.firstElementChild.cloneNode(true); return Array.from(template?.attributes ?? []).map(({ name }) => name); } get includeAttrs() { return this.getAttribute('include-attrs')?.split(' ') ?? []; } get excludeAttrs() { return this.getAttribute('exclude-attrs')?.split(' ') ?? []; } render() { render( template({ styles, }), this.shadowRoot ); } get media() { return this.querySelector('[slot=media]'); } } function updateMediaState({ type, target }) { // Destructure event.target in the throttled function, event.target becomes null. throttledPropogateMediaAttrs({ type, target }); throttledUpdateUrlState({ type, target }); } const throttledPropogateMediaAttrs = throttle(propogateMediaAttrs, 333); /** * Propagates media attributes to all children that observe media-show * and have the attribute in their observedAttributes. */ async function propogateMediaAttrs({ type, target }) { // Filter some attributes to not over tax setting attributes in the loop below. const attrFilter = { timeupdate: 'current-time', progress: 'buffered', volumechange: ['volume', 'muted'], }[type]; const showcase = closestComposedNode('media-show', target); await Promise.all([...showcase.querySelectorAll('*')] .filter((child) => child.localName.includes('-')) .map((child) => customElements.whenDefined(child.localName))); [...showcase.querySelectorAll('*')] .filter( (child) => child.constructor.observedAttributes?.length && child.constructor.observedAttributes.includes('media-show') ) .forEach((msChild) => getStateAttrs(showcase, mediaAttrs) .filter((attr) => !attrFilter || attrFilter.includes(attr)) .forEach((attr) => { const propName = camelCase(attr); const mediaValue = showcase.media[propName]; // Might be too brittle, filter on `name` and `observe` attribute. if ( !msChild.name || msChild.name === attr || kebabCase(msChild.name) === attr || msChild.getAttribute('observe')?.split(' ').includes(attr) ) { propogateMediaAttribute(msChild, `media-${attr}`, mediaValue); } }) ); } function propogateMediaAttribute(el, name, value) { if (value instanceof TimeRanges) { value = serializeTimeRanges(value); if (!value) return; } if ( el.constructor.observedAttributes?.includes(name) && el.getAttribute(name) !== String(value) ) { if (value == null || value === false) el.removeAttribute(name); else el.setAttribute(name, value === true ? '' : value); } } const throttledUpdateUrlState = throttle(updateUrlState, 333); function updateUrlState({ type, target }) { // Filter some attributes to not over tax setting attributes in the loop below. const attrFilter = { timeupdate: 'current-time', progress: 'buffered', volumechange: ['volume', 'muted'], }[type]; const showcase = closestComposedNode('media-show', target); getStateAttrs(showcase, urlStateAttrs) .filter((attr) => !attrFilter || attrFilter.includes(attr)) .forEach((attr) => { const propName = camelCase(attr); let value = showcase.media[propName]; if (typeof value === 'number') value = round(value, 1); if (getParam(attr) === String(value)) return; const search = toQuery( { ...getParams(), [attr]: value, }, showcase.mediaDefaults ); const url = new URL(window.location.href); url.search = search; history.pushState({}, '', url); }); } if (!customElements.get('media-show')) { customElements.define('media-show', MediaShow); } export { MediaShow };