import { isString, isNumber } from '../../lib/util/object.js';
import sortElements from '../../lib/dom/sortElements';
import DomSmith from '../../lib/dom/DomSmith.js';
import domSmithTooltip from '../util/domSmithTooltip.js';
import domSmithInputRange from '../../lib/dom/plugins/domSmithInputRange.js';
import domSmithSelect from '../../lib/dom/plugins/domSmithSelect.js';
// Register DomSmith plugins for enhanced UI handling (tooltips, input ranges, selects).
[domSmithTooltip, domSmithInputRange, domSmithSelect].forEach(plugin => DomSmith.registerPlugin(plugin));
/**
* This component manages the root DOM structure of the player. It is responsible for injecting, replacing, or appending the root wrapper element, according to the configured `placement` mode.
* All internal components depend on this element being mounted and available, as it acts as the main container and layout context for the entire player.
* It also includes layout logic for aspect ratio handling and emits well-defined lifecycle events for DOM readiness.
* Furthermore, this component also manages CSS styles and can insert it at different locations, with Vite HMR and Sourcemaps still intact while developing.
* This also enables support for Shadow DOM, so the player can be completely shielded against outer DOM and style access.
* @exports module:src/core/Dom
* @requires lib/util/object
* @requires lib/dom/sortElements
* @requires lib/dom/DomSmith
* @requires lib/dom/plugins/domSmithInputRange
* @requires lib/dom/plugins/domSmithSelect
* @requires src/util/domSmithTooltip
* @author Frank Kudermann - alphanull
* @version 1.0.0
* @license MIT
*/
export default class Dom {
/**
* Holds the instance configuration for this component.
* @type {Object}
* @property {'open'|'closed'|''} [shadow=''] Shadow DOM mode: `'closed`', `'open'`, or `''` (no Shadow DOM). If enabled, all player UI is rendered inside a shadow root for encapsulation and style isolation.
* @property {string} [className=''] Sets a custom classname on the player instance.
* @property {'auto'|'replace'|'append'|'before'} [insertMode='auto'] Where `insertMode` defines how the player is inserted into the DOM in conjunction with the `target` element. Can have the following values: `auto` generally appends to `target`, but replaces media elements and elements with a `vip-data-media attribute`, `append` treats the target element as parent to attach to, `replace` replaces the target element while `before` inserts the player before the target.
* @property {'dark'|'light'|'auto'} [darkMode='dark'] Sets the preferred visual mode for the player: `dark`, `light`, or `auto` for using system defaults.
* @property {string} [layout=''] Activates special layout modes. Currently supported: `controller-only`: Displays only the control interface (no video, canvas, or overlays). Used for audio playback.
* @property {number|string} [aspectRatio=16/9] Defines the aspect ratio of the player. Can be a numeric value like `16/9` or `1.777`, `'auto'` to automatically adapt to the current video, or `fill` to make layout depend on the container. Ignored when **both** `width` and `height` are defined.
* @property {boolean} [aspectRatioTransitions=false] If `true`, aspect ratio changes are animated (if supported by the browser).
* @property {number|string} [width=100%] Optional fixed width. If set as a number, it will be interpreted as pixels. If set as a string, it will be passed as-is to the CSS (e.g. `80vw`).
* @property {number|string} [height=''] Optional fixed height. If set as a number, it will be interpreted as pixels. If set as a string, it will be passed as-is to the CSS (e.g. `80vh`).
* @property {?HTMLElement} [_targetEle=null] The original target element calculated or received from the first `new VisionPlayer` argument. INTERNAL USE ONLY, not part of the official config.
*/
#config = {
shadow: '',
className: '',
insertMode: 'auto',
darkMode: 'dark',
layout: '',
aspectRatio: 16 / 9,
aspectRatioTransitions: false,
width: '100%',
height: '',
_targetEle: null // do not use this in configs!
};
/**
* Reference to the main player instance.
* @type {module:src/core/Player}
*/
#player;
/**
* Holds tokens of subscriptions to player events, for later unsubscribe.
* @type {number[]}
*/
#subscriptions;
/**
* Secret key only known to the player instance and initialized components.
* Used to be able to restrict access to API methods in conjunction with secure mode.
* @type {symbol}
*/
#apiKey;
/**
* Reference to the root element of the player.
* @type {HTMLElement}
*/
#dom;
/**
* Reference to the original target Element.
* @type {HTMLElement}
*/
#targetEle;
/**
* Reference to the ShadowRoot instance (if enabled).
* @type {ShadowRoot}
*/
#shadow;
/**
* Reference to the top-level wrapper node, which will be used when shado wmode is active.
* @type {HTMLElement}
*/
#wrapper;
/**
* Map holding all style elements for live updates (key: style id/url, value: \<style\> node).
* @type {Map<string, HTMLStyleElement>}
*/
#styleEles = new Map();
/**
* Creates an instance of the Dom component.
* Also prepares the root dom element based on the player config.
* @param {module:src/core/Player} player Reference to the VisionPlayer instance.
* @param {module:src/core/Player} parent Reference to the parent instance.
* @param {Object} [options] Additional options.
* @param {symbol} [options.apiKey] Token for extended access to the player API.
* @throws {Error} If trying to disable this component.
*/
constructor(player, parent, { apiKey }) {
const shadowDefault = this.#config.shadow;
this.#config = player.initConfig('dom', this.#config);
if (!this.#config) throw new Error('[Visionplayer] Cannot disable the Dom component by configuration.');
// if shadow defaults are already set, prevent further change
if (shadowDefault && !this.#config.shadow) this.#config.shadow = shadowDefault;
this.#apiKey = apiKey;
Dom.#instances.add(this);
this.#player = player;
this.#player.setApi('refreshDom', this.#refresh, true, apiKey);
this.#player.setApi('mountDom', this.#mount, true, apiKey);
this.#player.setApi('dom.getElement', this.#getElement, apiKey);
this.#player.setApi('dom.updateStyles', this.updateStyles, apiKey);
const hasCSSAspect = CSS.supports('aspect-ratio', '1/1'),
playerId = this.#player.getConfig('player.id');
this.#dom = new DomSmith({
_ref: 'root',
_tag: 'vision-player',
id: playerId,
className: `${this.#config.className || ''}${hasCSSAspect ? '' : ' has-aspect-patch'}`,
tabIndex: -1,
'data-useragent': navigator.userAgent,
_nodes: hasCSSAspect ? null
: [{
_ref: 'aspectHelper',
className: 'vip-aspect-helper'
}]
});
if (this.#config.shadow) {
this.#wrapper = new DomSmith({
_ref: 'wrapper',
_tag: 'vision-player',
id: playerId,
className: this.#dom.root.className
});
this.#dom.root.classList.add('is-shadow');
this.#shadow = this.#wrapper.wrapper.attachShadow({ mode: this.#config.shadow });
this.#shadow.appendChild(this.#dom.root);
} else this.#wrapper = this.#dom;
domSmithTooltip.setParent(this.#dom.root);
const { layout, aspectRatio, aspectRatioTransitions, width, height, darkMode, _targetEle } = this.#config;
this.#targetEle = _targetEle;
if (this.#config.insertMode === 'auto') {
const isMediaElement = _targetEle instanceof HTMLVideoElement || _targetEle instanceof HTMLAudioElement,
hasData = _targetEle.getAttribute('data-vip-media');
this.#config.insertMode = isMediaElement || hasData ? 'replace' : 'append';
}
// handle custom width and height
if (width && aspectRatio !== 'fill') this.#dom.root.style.width = isNaN(width) ? width : `${Number(width)}px`;
if (height && aspectRatio !== 'fill') this.#dom.root.style.height = isNaN(height) ? height : `${Number(height)}px`;
this.#dom.root.classList.toggle('has-ar-transitions', aspectRatioTransitions);
this.#dom.root.classList.toggle('is-light', darkMode === 'light');
this.#dom.root.classList.toggle('is-dark', darkMode === 'dark');
if (this.#config.shadow) {
this.#dom.root.parentNode.host.classList.toggle('is-light', darkMode === 'light');
this.#dom.root.parentNode.host.classList.toggle('is-dark', darkMode === 'dark');
}
if (aspectRatio && !layout && (!width || !height)) {
let currentAr;
/**
* Updates the aspect ratio and UI state (portrait/landscape).
* @param {number} ratio The aspect ratio to set.
*/
const setAspectRatio = ratio => {
const ar = isNaN(ratio) ? 16 / 9 : ratio;
if (currentAr === ar) return;
currentAr = ar;
this.#dom.root.classList.toggle('is-portrait', ar < 1);
if (this.#config.shadow) this.#dom.root.parentNode.host.classList.toggle('is-portrait', ar < 1);
this.#dom.aspectHelper?.style.setProperty('padding-bottom', `${1 / ar * 100}%`);
if (hasCSSAspect) this.#dom.root.style.aspectRatio = ar;
if (hasCSSAspect && this.#config.shadow) this.#dom.root.parentNode.host.style.aspectRatio = ar;
};
if (aspectRatio === 'auto') {
if (this.#targetEle?.videoHeight > 0) setAspectRatio(this.#targetEle.videoWidth / this.#targetEle.videoHeight);
// wait for media loaded to resize
this.#subscriptions = this.#player.subscribe('media/loadeddata', () => {
const w = this.#player.getState('media.videoWidth'),
h = this.#player.getState('media.videoHeight');
setAspectRatio(w / h);
});
} else if (aspectRatio === 'fill') {
this.#dom.root.classList.add('has-layout-filled');
} else if (isNumber(aspectRatio)) {
if (!width && height) this.#dom.root.style.width = 'auto';
setAspectRatio(aspectRatio);
}
}
if (layout) {
if (isString(layout)) this.#dom.root.classList.add(`layout-${layout}`);
if (layout === 'controller-only') {
// adapt player config to controller only layout
// force disable almost any UI related components, TODO: is hardcoded, has to know about components
const { ui } = this.#player.getConfig(),
overrideConfig = {
subtitles: false,
pictureInPicture: false,
overlays: false,
file: false,
playOverlay: false,
chromeCast: false,
airPlay: false,
notifications: false,
title: false,
thumbnails: false,
keyboard: false,
fullScreen: false,
videoControls: false,
visualizerAmbient: false,
visualizerBar: false,
ui: ui === false ? false : { alwaysVisible: true },
spinner: false,
scrubber: { placement: 'buttons' }
};
overrideConfig.scrubber = { placement: 'buttons' };
this.#player.setConfig(overrideConfig);
}
}
this.#initStyles();
}
/**
* Injects all global stylesheets into the current scope (head or Shadow DOM).
*/
#initStyles() {
const container = this.#shadow || document.head,
styleSheets = document.querySelectorAll('head > style');
const isStyleDuplicate = key => {
if (container === document.head) {
for (const style of styleSheets) {
if (style.getAttribute('data-vip-style') === key) return true;
}
}
return false;
};
const createEle = (key, value) => {
const styleEle = document.createElement('style');
styleEle.type = 'text/css';
styleEle.textContent = value;
styleEle.setAttribute('data-vip-style', key);
container.appendChild(styleEle);
this.#styleEles.set(key, styleEle);
};
if (import.meta.env.DEV) {
// Dev mode: insert all styles in separate tags (so HMR still works)
Dom.#styles.forEach((value, key) => {
// prevent duplicate styles in head
if (isStyleDuplicate(key)) return;
createEle(key, value);
});
} else {
// Build Mode: concatenate all css and use single tag
if (isStyleDuplicate('vip-main-css')) return;
let css = '';
Dom.#styles.forEach(value => { css += value; });
createEle('vip-main-css', css);
}
}
/**
* Called - via private API - from the player class if all components have been reloaded due to a config change.
* Resorts the root Dom and fires events again so components can catch up.
* @fires module:src/core/Dom#dom/ready
* @fires module:src/core/Dom#dom/beforemount
*/
#refresh = () => {
sortElements(this.#dom.root);
this.#player.publish('dom/beforemount', null, { async: false }, this.#apiKey);
this.#player.publish('dom/ready', null, { async: false }, this.#apiKey);
};
/**
* Inserts the root dom element into the host document.
* @fires module:src/core/Dom#dom/ready
* @fires module:src/core/Dom#dom/beforemount
*/
#mount = () => {
sortElements(this.#dom.root);
this.#player.publish('dom/beforemount', null, { async: false }, this.#apiKey);
this.#wrapper.mount({
ele: this.#targetEle,
insertMode: this.#config.insertMode
});
this.#player.publish('dom/ready', null, { async: false }, this.#apiKey);
};
/**
* Used by child components to retrieve the player root element.
* Access is restricted by apiKey if secure mode is enabled.
* @param {symbol} apiKey Token needed to grant access in secure mode.
* @returns {HTMLElement} The player root element.
* @throws {Error} If called in secure mode without valid key.
*/
#getElement = apiKey => {
if (this.#apiKey && this.#apiKey !== apiKey) {
throw new Error('[Visionplayer] Secure mode: access denied.');
}
return this.#dom.root;
};
/**
* Updates the content of all style elements in this instance using the provided array of style objects. Used for live HMR updates.
* @param {Array<{key: string, css: string}>} styles Array of style updates (key: style id/url, css: new content).
*/
updateStyles = styles => {
styles.forEach(({ key, css }) => {
this.#styleEles.get(key).textContent = css;
});
};
/**
* Cleans up the Dom component by unsubscribing from events and removing style elements.
*/
destroy() {
Dom.#instances.delete(this);
domSmithTooltip.removeParent();
// TODO: this wont work correctly in the edge case when there are several mixed shadow / non shadow instances
// and the last one to be cleared is a shadow, while the second last is not. Needs additional state
// maybe implement #instances as a Map with "isShadow" as the value?
if (this.#shadow || Dom.#instances.size === 0) {
this.#styleEles.forEach(ele => ele.remove());
}
this.#styleEles.clear();
this.#wrapper.destroy();
this.#player.removeApi(['refreshDom', 'mountDom', 'dom.getElement', 'dom.updateStyles'], this.#apiKey);
this.#player.unsubscribe(this.#subscriptions);
this.#player = this.#dom = this.#targetEle = this.#styleEles = this.#apiKey = null;
}
/**
* Global map holding all loaded style contents (key: absolute style path, value: CSS).
* @type {Map<string, string>}
*/
static #styles = new Map();
/**
* Set of all current Dom component instances.
* @type {Set<module:src/core/Dom>}
*/
static #instances = new Set();
/**
* Registers API hooks on the Player class for style injection and HMR update.
* @param {module:src/core/Player} Player Reference to the Player constructor.
*/
static initialize(Player) {
Player.setApi('addStyles', Dom.#addStyles);
Player.setApi('updateStyles', Dom.#updateStyles);
}
/**
* Adds a style sheet to the global styles map (used by all player instances).
* @param {string} path Relative or absolute style path (e.g., `'assets/scss/core/player.scss'`).
* @param {string} css Raw CSS string (usually imported with ?inline).
*/
static #addStyles = (path, css) => {
const absolutePath = `/${path.replace(/^([./])+/, '')}`;
Dom.#styles.set(absolutePath, css);
};
/**
* Triggers an updateStyles call on all Dom instances, used for HMR.
* @param {Array<{key: string, css: string}>} styles Array of updated styles (key/url, css string).
*/
static #updateStyles = styles => {
for (const instance of this.#instances) {
instance.updateStyles(styles);
}
};
}
/**
* This event is fired when the player has initialized all components, but is not added to the DOM yet.
* @event module:src/core/Dom#dom/beforemount
*/
/**
* This event is fired when the player was just added to the target DOM.
* @event module:src/core/Dom#dom/ready
*/