import { MediaUIAttributes, MediaUIEvents, MediaStateReceiverAttributes, PointerTypes, } from './constants.js'; import { closestComposedNode, getBooleanAttr, namedNodeMapToObject, setBooleanAttr, } from './utils/element-utils.js'; import { globalThis } from './utils/server-safe-globals.js'; function getTemplateHTML(_attrs: Record) { return /*html*/ ` `; } /** * @extends {HTMLElement} * * @attr {boolean} mediapaused - (read-only) Present if the media is paused. * @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within). * * @cssproperty --media-gesture-receiver-display - `display` property of gesture receiver. * @cssproperty --media-control-display - `display` property of control. */ class MediaGestureReceiver extends globalThis.HTMLElement { static shadowRootOptions = { mode: 'open' as ShadowRootMode }; static getTemplateHTML = getTemplateHTML; #mediaController; // NOTE: Currently "baking in" actions + attrs until we come up with // a more robust architecture (CJP) static get observedAttributes(): string[] { return [ MediaStateReceiverAttributes.MEDIA_CONTROLLER, MediaUIAttributes.MEDIA_PAUSED, ]; } _pointerType: string; constructor() { super(); if (!this.shadowRoot) { // Set up the Shadow DOM if not using Declarative Shadow DOM. this.attachShadow((this.constructor as typeof MediaGestureReceiver).shadowRootOptions); const attrs = namedNodeMapToObject(this.attributes); this.shadowRoot.innerHTML = (this.constructor as typeof MediaGestureReceiver).getTemplateHTML(attrs); } } attributeChangedCallback( attrName: string, oldValue: string | null, newValue: string | null ): void { 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); } } } connectedCallback(): void { this.tabIndex = -1; this.setAttribute('aria-hidden', 'true'); this.#mediaController = getMediaControllerEl(this); if (this.getAttribute(MediaStateReceiverAttributes.MEDIA_CONTROLLER)) { this.#mediaController?.associateElement?.(this); } if (!this.#mediaController) return this.#mediaController.addEventListener('pointerdown', this); this.#mediaController.addEventListener('click', this); /* * Note: According to ARIA: "Clickable elements must be focusable and should have interactive semantics" * Since this class adds the click listener, it also makes it focusable */ if (!this.#mediaController.hasAttribute("tabindex")) { this.#mediaController.tabIndex = 0; } } disconnectedCallback(): void { // Use cached mediaController, getRootNode() doesn't work if disconnected. if (this.getAttribute(MediaStateReceiverAttributes.MEDIA_CONTROLLER)) { this.#mediaController?.unassociateElement?.(this); } this.#mediaController?.removeEventListener('pointerdown', this); this.#mediaController?.removeEventListener('click', this); this.#mediaController = null; } handleEvent(event): void { const composedTarget = event.composedPath()?.[0]; const allowList = ['video', 'media-controller']; if (!allowList.includes(composedTarget?.localName)) return; if (event.type === 'pointerdown') { // Since not all browsers have updated to be spec compliant, where 'click' events should be PointerEvents, // we can use use 'pointerdown' to reliably determine the pointer type. (CJP). this._pointerType = event.pointerType; } else if (event.type === 'click') { // Cannot use composedPath or target because this is a layer on top and pointer events are disabled. // Attach to window and check if click is in this element's bounding box to keep