Skip to content

Source: src/ui/Thumbnails.js

import DomSmith from '../../lib/dom/DomSmith.js';
import { isString, isObject } from '../../lib/util/object.js';

/**
 * The Thumbnails component displays preview images while scrubbing and hovering on the timeline.
 * It uses a sprite sheet or grid-based image layout to show frame-accurate thumbnails in the scrubber tooltip and a larger preview above the player if enabled.
 * The component dynamically adapts its layout and supports language-specific thumbnails when defined.
 * @exports module:src/ui/Thumbnails
 * @requires lib/dom/DomSmith
 * @requires lib/util/object
 * @author   Frank Kudermann - alphanull
 * @version  1.0.2
 * @license  MIT
 */
export default class Thumbnails {
    /**
     * Configuration options for the Thumbnails component.
     * @type     {Object}
     * @property {boolean} [showInScrubber=true]  Displays a thumbnail inside the scrubber tooltip.
     * @property {boolean} [showPreview=true]     Displays a larger preview overlay while scrubbing.
     */
    #config = {
        showInScrubber: true,
        showPreview: true
    };

    /**
     * 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;

    /**
     * Additional subscriptions once we confirm we have thumbnails data.
     * @type {number[]}
     */
    #subs = [];

    /**
     * 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;

    /**
     * If enabled, holds a DOM node for the scrubber tooltip.
     * @type {module:lib/dom/DomSmith|undefined}
     */
    #scrubber;

    /**
     * If enabled, holds a DOM node for a bigger preview while scrubbing.
     * @type {module:lib/dom/DomSmith|undefined}
     */
    #preview;

    /**
     * Holds a copy of the media data thumbnail section.
     * @type {Object}
     */
    #data;

    /**
     * Internal object storing current thumbnail info (src, dimensions, etc.).
     * @type {module:src/core/Data~mediaItem_thumbnail}
     */
    #thumb = {};

    /**
     * Reference to the tooltip component.
     * @type {module:src/controller/ScrubberTooltip}
     */
    #tooltipComp;

    /**
     * Creates an instance of the  component.
     * @param {module:src/core/Player}           player            Reference to the VisionPlayer instance.
     * @param {module:src/controller/Controller} parent            Reference to the parent instance.
     * @param {Object}                           [options]         Additional options.
     * @param {symbol}                           [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, { apiKey }) {

        this.#config = player.initConfig('thumbnails', this.#config);
        this.#tooltipComp = player.getComponent('ui.controller.scrubber.tooltip', apiKey);

        if (this.#config.showInScrubber) this.#config.showInScrubber = Boolean(this.#tooltipComp);

        if (!this.#config || !this.#config.showInScrubber && !this.#config.showPreview) return [false];

        this.#player = player;
        this.#apiKey = apiKey;

        if (this.#config.showInScrubber) {
            this.#scrubber = new DomSmith({
                _ref: 'thumbWrapper',
                className: 'vip-scrubber-tooltip-thumb',
                _nodes: [{
                    _ref: 'thumbImg',
                    _tag: 'img',
                    alt: 'Thumbnail',
                    className: 'vip-scrubber-tooltip-thumb-img',
                    load: this.#onThumbLoaded,
                    error: this.#onThumbError
                }]
            });
        }

        if (this.#config.showPreview && !this.#player.getConfig('scrubber.continuousUpdate')) {
            this.#preview = new DomSmith({
                _ref: 'backdrop',
                className: 'vip-scrubber-thumb-preview is-hidden'
            }, this.#player.dom.getElement(apiKey));
        }

        this.#subscriptions = [
            this.#player.subscribe('data/ready', this.#onDataReady),
            this.#player.subscribe('media/ready', this.#onMediaReady)
        ];

    }

    /**
     * Called when the media data is ready, checks for "thumbnails" in the data, sets up events if found.
     * @param {module:src/core/Data~mediaItem}           mediaItem               Object containing media data.
     * @param {module:src/core/Data~mediaItem_thumbnail} [mediaItem.thumbnails]  The thumbnail configuration from the media data.
     * @listens module:src/core/Data#data/ready
     */
    #onDataReady = ({ thumbnails }) => {

        this.#player.unsubscribe(this.#subs);
        this.#scrubber?.thumbWrapper.classList.remove('is-visible');

        if (!thumbnails) return;

        this.#scrubber?.thumbWrapper.classList.add('is-visible');

        this.#data = thumbnails;
        this.#subs = [];

        if (this.#config.showInScrubber) {
            this.#subs.push(
                this.#player.subscribe('scrubber/tooltip/visible', this.#onTooltipVisible),
                this.#player.subscribe('scrubber/tooltip/visible', this.#onThumbRender),
                this.#player.subscribe('scrubber/tooltip/move', this.#onThumbRender)
            );
        }

        if (this.#config.showPreview && !this.#player.getConfig('scrubber.continuousUpdate')) {
            this.#subs.push(
                this.#player.subscribe('scrubber/update', this.#onScrubberUpdate),
                this.#player.subscribe('scrubber/end', this.#onScrubberEnd),
                this.#player.subscribe('ui/resize', this.#resize)
            );
        }
    };

    /**
     * Called when the media is ready. If we have thumbnail data, we load the thumbnail image(s).
     * @param  {module:src/core/Media~metaData} metaData           The currently selected meta data.
     * @param  {string|Object<string,string>}   metaData.language  Possibly used if there are multiple language keys in "thumbnails.src".
     * @throws {Error}                                             If thumbnail format is invalid.
     * @listens module:src/core/Media#media/ready
     */
    #onMediaReady = ({ language, width, height }) => {

        const { thumbnails } = this.#player.data.getMediaData();

        if (!this.#config.showInScrubber || !thumbnails) return;

        if (isString(thumbnails.src)) {

            if (this.#thumb.src === thumbnails.src) return; // dont load same img twice

            this.#thumb = { src: thumbnails.src };
            this.#scrubber.thumbImg.src = thumbnails.src;

            if (this.#config.showPreview) {
                this.#preview.backdrop.style.backgroundImage = `url(${thumbnails.src})`;
            }

        } else if (isObject(thumbnails.src)) {

            if (thumbnails.src[language]) {

                if (this.#thumb.src === thumbnails.src[language]) return;

                this.#thumb = { src: thumbnails.src[language] };
                this.#scrubber.thumbImg.src = thumbnails.src[language];

                if (this.#config.showPreview) {
                    this.#preview.backdrop.style.backgroundImage = `url(${thumbnails.src[language]})`;
                }

            } else {
                this.#scrubber.thumbWrapper.classList.add('has-error');
                return;
            }

        } else throw new Error('[VisionPlayer] Invalid thumbnail format');

        this.#scrubber.thumbWrapper.classList.remove('has-error');
        this.#scrubber.thumbWrapper.classList.add('is-loading');

        this.#thumb.ar = height / width;

        if (this.#config.showPreview) {
            this.#preview.backdrop.style.backgroundSize = `${100 * this.#data.gridX}%`;
        }

    };

    #onTooltipVisible = () => {

        const { width } = this.#scrubber.thumbImg,
              thumbWidth = this.#scrubber.thumbWrapper.offsetWidth,
              uiScale = this.#player.getState('ui.scale');

        this.#thumb.width = thumbWidth;
        this.#thumb.height = thumbWidth * this.#thumb.ar + uiScale / 2.5;
        this.#thumb.scale = thumbWidth / (width / this.#data.gridX);

        this.#player.dom.getElement(this.#apiKey).style.setProperty('--vip-scrubber-thumbnail-height', `${this.#thumb.height - 2}px`);

    };

    /**
     * Called when the thumbnail image is loaded. Computes scaling and updates the layout.
     */
    #onThumbLoaded = () => {

        this.#scrubber.thumbWrapper.classList.remove('is-loading');

        this.#resize(); // TODO: prevent loading more than once later

        this.#scrubber.unmount();
        this.#scrubber.mount(this.#tooltipComp.getElement());

    };

    /**
     * Called if the thumbnail image fails to load, showing an error style.
     */
    #onThumbError = () => {

        this.#scrubber.thumbWrapper.classList.remove('is-loading');
        this.#scrubber.thumbWrapper.classList.add('has-error');

    };

    /**
     * Renders a portion of the thumbnail for the given scrubber tooltip position.
     * @param {number} percent  The fraction of the media timeline (0..1).
     * @listens module:src/controller/ScrubberTooltip#scrubber/tooltip/visible
     * @listens module:src/controller/ScrubberTooltip#scrubber/tooltip/move
     */
    #onThumbRender = ({ percent }) => {

        if (!this.#config.showInScrubber || !this.#thumb.width) return;

        const currentTime = this.#player.getState('media.duration') * Math.min(Math.max(percent / 100, 0), 1),
              frame = Math.floor(currentTime * 1 / this.#data.timeDelta),
              frameY = Math.floor(frame / this.#data.gridX),
              frameX = frame % this.#data.gridX;

        this.#scrubber.thumbImg.style.transform = `
            translateX(${-frameX * this.#thumb.width}px)
            translateY(${-frameY * this.#thumb.height - 1}px)
            scale(${this.#thumb.scale})`;

    };

    /**
     * Handles scrubber moves to show a bigger preview (if enabled).
     * @param {number} percent  The fraction of the media timeline (0..1).
     * @listens module:src/controller/Scrubber#scrubber/update
     */
    #onScrubberUpdate = ({ percent }) => {

        this.#preview.backdrop.classList.remove('is-hidden');

        const currentTime = this.#player.getState('media.duration') * Math.min(Math.max(percent / 100, 0), 1),
              frame = Math.floor(currentTime * 1 / this.#data.timeDelta),
              frameY = Math.floor(frame / this.#data.gridX),
              frameX = frame % this.#data.gridX;

        this.#preview.backdrop.style.backgroundPosition = `
            ${100 * (frameX / (this.#data.gridX - 1))}%
            ${100 * (frameY / (this.#data.gridY - 1))}%`;

    };

    /**
     * Called when scrubbing ends, hides the bigger preview.
     * @listens module:src/controller/Scrubber#scrubber/end
     */
    #onScrubberEnd = () => {

        this.#preview.backdrop.classList.add('is-hidden');

    };

    /**
     * Called on UI resize, adjusts the bigger preview's aspect ratio scaling to match the viewport.
     * @param {Object} size           Object containing the new size information.
     * @param {number} [size.width]   The new player width.
     * @param {number} [size.height]  The new player height.
     * @listens module:src/ui/UI#ui/resize
     */
    #resize = ({ width = this.#player.getState('ui.playerWidth'), height = this.#player.getState('ui.playerHeight') } = {}) => {

        const arViewport = height / width,
              arImg = this.#thumb.height / this.#thumb.width,
              scaleWidth = arViewport > arImg,
              scalePercent = scaleWidth ? `${Math.round(arImg / arViewport * 100)}%` : `${Math.round(arViewport / arImg * 100)}%`;

        this.#preview.backdrop.style.width = scaleWidth ? '100%' : scalePercent;
        this.#preview.backdrop.style.height = scaleWidth ? scalePercent : '100%';

    };

    /**
     * Returns the container element for this component, if needed.
     * @returns {HTMLElement|undefined} The Tooltip Element.
     */
    getElement() {

        return this.#scrubber?.tooltip;

    }

    /**
     * This method removes all events, subscriptions and DOM nodes created by this component.
     */
    destroy() {

        this.#scrubber?.destroy();
        this.#preview?.destroy();
        this.#player.unsubscribe(this.#subscriptions);
        this.#player.unsubscribe(this.#subs);
        this.#player = this.#scrubber = this.#preview = this.#apiKey = null;

    }

}