UNPKG

msc-any-pip

Version:

Imaging what if we can let anything Picture-in-Picture (not only <video />) ?! Here comes <msc-any-pip /> to let it dream comes true. <msc-any-pip /> apply Document Picture-in-Picture API to given elements have Picture-in-Picture feature.

395 lines (325 loc) 11.2 kB
import { _wcl } from './common-lib.js'; import { _wccss } from './common-css.js'; /* reference: - documentPictureInPicture min-size: 300 x 300 - https://developer.chrome.com/docs/web-platform/document-picture-in-picture/ */ const defaults = { winwidth: 0, winheight: 0, preferinitialwindowplacement: false, disallowreturntoopener: false, dismisswhenbacktoopener: false }; const booleanAttrs = ['disallowreturntoopener', 'preferinitialwindowplacement', 'dismisswhenbacktoopener']; // booleanAttrs default should be false const objectAttrs = []; const custumEvents = { piping: 'msc-any-pip-piping', pipingEnd: 'msc-any-pip-pip-end' }; const template = document.createElement('template'); template.innerHTML = ` <style> ${_wccss} :host { --block-size: auto; position: relative; display: block; } :host(.align-container-size) { --block-size: 100%; block-size: 100%; } .main { --icon-pip: path('M2.4,11.4v-2H6L1.7,5.1l1.4-1.4L7.4,8V4.4h2v7H2.4z M4.4,20.4c-0.5,0-1-0.2-1.4-0.6c-0.4-0.4-0.6-0.9-0.6-1.4v-5h2v5h8v2 H4.4z M20.4,13.4v-7h-9v-2h9c0.5,0,1,0.2,1.4,0.6c0.4,0.4,0.6,0.9,0.6,1.4v7H20.4z M14.4,20.4v-5h8v5H14.4z'); --piping-text: var(--msc-any-pip-piping-text, 'Playing in Picture-in-Picture.'); --piping-color: var(--msc-any-pip-piping-color, #39e75f); --piping-font-size: var(--msc-any-pip-piping-font-size, 16px); --btn-size: 40px; --btn-icon-color: rgba(255 255 255); --btn-z-index: var(--msc-any-pip-button-z-index, 1); block-size: var(--block-size); } .btn-pip { --btn-scale-normal: 1; --btn-scale-active: 0; --btn-scale: var(--btn-scale-normal); --btn-opacity-normal: .5; --btn-opacity-active: .8; --btn-opacity: var(--btn-opacity-normal); --btn-background-color: rgba(0 0 0/var(--btn-opacity)); } .main--active{} .main--active::after{position:absolute;inset-inline-start:50%;inset-block-start:50%;transform:translate(-50%,-50%);inline-size:80%;text-align:center;text-wrap:balance;line-height:1.3;content:var(--piping-text);color:var(--piping-color);font-size:var(--piping-font-size);} :is(.main--active,.main--active--not-support) .btn-pip{--btn-scale:var(--btn-scale-active);} .main{position:relative;inline-size:100%;outline:0 none;} .btn-pip{position:absolute;inset-inline-end:8px;inset-block-start:8px;inline-size:var(--btn-size);aspect-ratio:1/1;font-size:0;color:transparent;background:var(--btn-background-color);border-radius:var(--btn-size);padding:0;margin:0;appearance:none;border:0 none;box-shadow:none;display:grid;place-content:center;outline:0 none;overflow:hidden;scale:var(--btn-scale);transition:background-color 200ms ease-in-out,scale 150ms linear;z-index:var(--btn-z-index);} .btn-pip::before{content:'';inline-size:24px;aspect-ratio:1/1;background-color:var(--btn-icon-color);display:block;clip-path:var(--icon-pip);} .btn-pip:active{scale:.8;transition-duration:0ms;} .btn-pip:focus-visible{--btn-opacity:var(--btn-opacity-active);} .main__slot{position:relative;inline-size:100%;block-size:var(--block-size);display:block;} .main__slot::slotted(*){max-inline-size:100%;} .main__slot::slotted(.msc-any-pip-cloned){visibility:hidden;opacity:0;pointer-events:none;} @media (hover: hover) { .btn-pip:hover{--btn-opacity:var(--btn-opacity-active);} } </style> <div class="main" ontouchstart="" tabindex="0"> <slot class="main__slot"></slot> <button class="btn-pip" type="button" title="Picture in Picture" aria-label="Picture in Picture">pip</button> </div> `; // Houdini Props and Vals, https://web.dev/at-property/ if (CSS?.registerProperty) { try { CSS.registerProperty({ name: '--msc-any-pip-piping-font-size', syntax: '<length>', inherits: true, initialValue: '16px' }); CSS.registerProperty({ name: '--msc-any-pip-piping-color', syntax: '<color>', inherits: true, initialValue: '#39e75f' }); CSS.registerProperty({ name: '--msc-any-pip-button-z-index', syntax: '<number>', inherits: true, initialValue: '1' }); } catch(err) { console.warn(`msc-any-pip: ${err.message}`); } } export class MscAnyPip extends HTMLElement { #data; #nodes; #config; constructor(config) { super(); // template this.attachShadow({ mode: 'open', delegatesFocus: true }); this.shadowRoot.appendChild(template.content.cloneNode(true)); // data this.#data = { controller: '' }; // nodes this.#nodes = { styleSheet: this.shadowRoot.querySelector('style'), main: this.shadowRoot.querySelector('.main'), trigger: this.shadowRoot.querySelector('.btn-pip') }; // config this.#config = { ...defaults, ...config // new MscAnyPip(config) }; // evts this._onClick = this._onClick.bind(this); } async connectedCallback() { const { config, error } = await _wcl.getWCConfig(this); const { main, trigger } = this.#nodes; if (error) { console.warn(`${_wcl.classToTagName(this.constructor.name)}: ${error}`); this.remove(); return; } else { this.#config = { ...this.#config, ...config }; } // upgradeProperty Object.keys(defaults).forEach((key) => this.#upgradeProperty(key)); // remove script[type=application/json] Array.from(this.querySelectorAll(':scope > script[type="application/json"]')).forEach((script) => script.remove()); // not suppout if (!window?.documentPictureInPicture) { main.classList.add('main--active--not-support'); } // evts this.#data.controller = new AbortController(); const signal = this.#data.controller.signal; trigger.addEventListener('click', this._onClick, { signal }); } disconnectedCallback() { if (this.#data?.controller) { this.#data.controller.abort(); } } #format(attrName, oldValue, newValue) { const hasValue = newValue !== null; if (!hasValue) { if (booleanAttrs.includes(attrName)) { this.#config[attrName] = false; } else { this.#config[attrName] = defaults[attrName]; } } else { switch (attrName) { case 'winwidth': case 'winheight': this.#config[attrName] = _wcl.isNumeric(newValue) ? parseFloat(newValue) : defaults[attrName]; break; case 'disallowreturntoopener': case 'preferinitialwindowplacement': case 'dismisswhenbacktoopener': this.#config[attrName] = true; break; } } } attributeChangedCallback(attrName, oldValue, newValue) { if (!MscAnyPip.observedAttributes.includes(attrName)) { return; } this.#format(attrName, oldValue, newValue); switch (attrName) { default: break; } } static get observedAttributes() { return Object.keys(defaults); // MscAnyPip.observedAttributes } static get supportedEvents() { return Object.keys(custumEvents).map( (key) => { return custumEvents[key]; } ); } #upgradeProperty(prop) { let value; if (MscAnyPip.observedAttributes.includes(prop)) { if (Object.prototype.hasOwnProperty.call(this, prop)) { value = this[prop]; delete this[prop]; } else { if (booleanAttrs.includes(prop)) { value = (this.hasAttribute(prop) || this.#config[prop]) ? true : false; } else if (objectAttrs.includes(prop)) { value = this.hasAttribute(prop) ? this.getAttribute(prop) : JSON.stringify(this.#config[prop]); } else { value = this.hasAttribute(prop) ? this.getAttribute(prop) : this.#config[prop]; } } this[prop] = value; } } set winwidth(value) { if (value) { this.setAttribute('winwidth', value); } else { this.removeAttribute('winwidth'); } } get winwidth() { const { width } = this.getBoundingClientRect(); return this.#config.winwidth !== 0 ? this.#config.winwidth : width; } set winheight(value) { if (value) { this.setAttribute('winheight', value); } else { this.removeAttribute('winheight'); } } get winheight() { const { height } = this.getBoundingClientRect(); return this.#config.winheight !== 0 ? this.#config.winheight : height; } set preferinitialwindowplacement(value) { this.toggleAttribute('preferinitialwindowplacement', Boolean(value)); } get preferinitialwindowplacement() { return this.#config.preferinitialwindowplacement; } set disallowreturntoopener(value) { this.toggleAttribute('disallowreturntoopener', Boolean(value)); } get disallowreturntoopener() { return this.#config.disallowreturntoopener; } set dismisswhenbacktoopener(value) { this.toggleAttribute('dismisswhenbacktoopener', Boolean(value)); } get dismisswhenbacktoopener() { return this.#config.dismisswhenbacktoopener; } #fireEvent(evtName, detail) { this.dispatchEvent(new CustomEvent(evtName, { bubbles: true, composed: true, ...(detail && { detail }) } )); } async _onClick() { const children = [...this.children]; if (!children.length || !window.documentPictureInPicture) { return; } const clones = children.map( (element) => { const clone = element.cloneNode(true); clone.classList.add('msc-any-pip-cloned'); return clone; } ); // pip const pipWindow = await window.documentPictureInPicture.requestWindow({ width: this.winwidth, height: this.winheight, disallowReturnToOpener: this.disallowreturntoopener, preferInitialWindowPlacement: this.preferinitialwindowplacement }); _wcl.cloneStyleSheetsToDocument(pipWindow.document); children.forEach((child) => pipWindow.document.body.append(child)); clones.forEach((child) => this.append(child)); this.#nodes.main.classList.toggle('main--active', true); this.#fireEvent(custumEvents.piping); // event pipWindow.addEventListener('pagehide', () => { clones.forEach((child) => child.remove()); children.forEach((child) => this.append(child)); this.#nodes.main.classList.toggle('main--active', false); this.#fireEvent(custumEvents.pipingEnd); }, { once:true } ); } requestPictureInPicture() { this.#nodes.trigger.click(); } } // define web component const S = _wcl.supports(); const T = _wcl.classToTagName('MscAnyPip'); if (S.customElements && S.shadowDOM && S.template && !window.customElements.get(T)) { window.customElements.define(_wcl.classToTagName('MscAnyPip'), MscAnyPip); } // close pipWin which opened by msc-any-pip window.customElements.whenDefined('msc-any-pip').then( () => { document.addEventListener('visibilitychange', () => { if (document.hidden) { return; } const active = document.querySelector('msc-any-pip[dismisswhenbacktoopener] .msc-any-pip-cloned'); if (active && window.documentPictureInPicture?.window) { window.documentPictureInPicture.window.close(); } } ); } );