Skip to content

Source: src/ui/Chapters.js

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

/**
 * The Chapters component provides visual representations of media chapters across different UI locations.
 * It enhances navigation and content awareness by highlighting current chapter positions and offering next/previous controls.
 * The chapter titles are localized and updated dynamically in the tooltip and controller based on playback time.
 * @exports module:src/ui/Chapters
 * @requires lib/util/object
 * @requires lib/dom/DomSmith
 * @author   Frank Kudermann - alphanull
 * @version  1.0.1
 * @license  MIT
 */
export default class Chapters {

    /**
     * Configuration options for the Chapters component.
     * @type     {Object}
     * @property {boolean} [showInScrubber=true]         Shows chapter segments along the scrubber timeline.
     * @property {boolean} [showInTooltip=true]          Shows chapter titles within the scrubber tooltip.
     * @property {boolean} [showInController=true]       Displays a controller item with chapter title and navigation controls.
     * @property {boolean} [showControllerButtons=true]  Shows previous/next chapter buttons in the controller.
     */
    #config = {
        showInScrubber: true,
        showInTooltip: true,
        showInController: true,
        showControllerButtons: 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;

    /**
     * Holds tokens of secondary subscriptions to player events, for later unsubscribe.
     * @type {number[]}
     */
    #subs;

    /**
     * If we show chapters on the scrubber timeline, build that DOM here.
     * @type {module:lib/dom/DomSmith|undefined}
     */
    #scrubber;

    /**
     * If we show chapters in the controller, build a small UI with prev/next and a title area.
     * @type {module:lib/dom/DomSmith|undefined}
     */
    #controller;

    /**
     * If we show chapters in a tooltip, set it up in the existing scrubber tooltip area.
     * @type {module:lib/dom/DomSmith|undefined}
     */
    #tooltipContainer;

    /**
     * The array of available chapters, sorted by start time.
     * @type {module:src/core/Data~mediaItem_chapter[]}
     */
    #chapters = [];

    /**
     * Index of the currently active chapter.
     * @type {number}
     */
    #chapter = 0;

    /**
     * Creates a new instance of the Chapters 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('chapters', this.#config);

        const scrubberComp = player.getComponent('ui.controller.scrubber', apiKey),
              tooltipComp = player.getComponent('ui.controller.scrubber.tooltip', apiKey);

        // If the user asked to show chapters in certain places, verify that those sub-components exist:
        if (this.#config.showInScrubber) this.#config.showInScrubber = Boolean(scrubberComp);
        if (this.#config.showInTooltip) this.#config.showInTooltip = Boolean(tooltipComp);

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

        this.#player = player;

        if (this.#config.showInScrubber) {
            this.#scrubber = new DomSmith({
                _ref: 'wrapper',
                className: 'vip-chapter-scrubber',
                _nodes: [{
                    _ref: 'scrubberInner',
                    className: 'vip-chapter-scrubber-inner'
                }]
            }, scrubberComp.getElement());
        }

        if (this.#config.showInTooltip) {
            this.#tooltipContainer = new DomSmith({
                _ref: 'tooltip',
                className: 'vip-chapter-tooltip',
                _nodes: [{
                    _ref: 'chapterTooltip',
                    _text: ''
                }]
            }, tooltipComp.getElement());
        }

        if (this.#config.showInController) {
            this.#controller = new DomSmith({
                _ref: 'wrapper',
                className: 'vip-chapter-controller',
                'data-sort': 50,
                _nodes: [{
                    className: 'vip-chapter-controller-nav',
                    _nodes: [{
                        _tag: 'button',
                        _ref: 'navPrev',
                        className: 'icon prev',
                        ariaLabel: this.#player.locale.t('chapter.prev'),
                        click: this.#onSwitchChapter,
                        $tooltip: { player, text: this.#player.locale.t('chapter.prev') }
                    }, {
                        _tag: 'button',
                        _ref: 'navNext',
                        className: 'icon next',
                        ariaLabel: this.#player.locale.t('chapter.next'),
                        click: this.#onSwitchChapter,
                        $tooltip: { player, text: this.#player.locale.t('chapter.next') }
                    }]
                }, {
                    className: 'vip-chapter-controller-text',
                    _nodes: [{
                        className: 'vip-chapter-controller-text-inner',
                        _ref: 'textWrapper',
                        ariaLabel: '',
                        role: 'text',
                        _nodes: [{
                            _tag: 'span',
                            ariaHidden: true,
                            _nodes: [{
                                _ref: 'wrapperText',
                                _text: ''
                            }]
                        }]
                    }]
                }]
            }, parent.getElement('center'));
        }

        this.#subscriptions = [
            this.#player.subscribe('data/ready', this.#onDataReady),
            this.#player.subscribe('data/nomedia', this.#disable),
            this.#player.subscribe('media/error', this.#disable),
            this.#player.subscribe('media/canplay', this.#enable)
        ];

    }

    /**
     * Called when the media data is ready. This obtains the "chapters" array if present,
     * and sets up or hides the relevant UI elements.
     * @param {module:src/core/Data~mediaItem}           mediaItem             Object containing media type info.
     * @param {module:src/core/Data~mediaItem_chapter[]} [mediaItem.chapters]  The array of chapters from the media data.
     * @listens  module:src/core/Data#data/ready
     */
    #onDataReady = ({ chapters }) => {

        const hasChapters = chapters && isArray(chapters) && chapters.length;

        this.#player.unsubscribe(this.#subs);
        this.#subs = [];

        if (hasChapters) this.#subs.push(this.#player.subscribe('media/ready', this.#onMediaReady));

        this.#chapters = hasChapters ? [...chapters].sort((a, b) => a.start - b.start) : [];

        // switch off controls when no chapter data present

        if (this.#config.showInTooltip) {
            this.#tooltipContainer.tooltip.style.display = hasChapters ? 'block' : 'none';
        }

        if (this.#config.showInController) {
            this.#controller.wrapper.classList.toggle('is-hidden', !hasChapters);
            if (hasChapters) this.#subs.push(this.#player.subscribe('media/timeupdate', this.#onTimeUpdate));
        }

        if (this.#config.showInScrubber) {
            this.#scrubber.wrapper.style.display = hasChapters ? 'flex' : 'none';
            if (hasChapters) this.#subs.push(this.#player.subscribe('scrubber/tooltip', this.#onToolTip));
        }

    };

    /**
     * Called when the media is ready. This places the chapter segments on the scrubber if needed,
     * and triggers a time update to set the initial UI.
     * @listens module:src/core/Media#media/ready
     */
    #onMediaReady = () => {

        if (!this.#chapters.length) return;

        if (this.#config.showInScrubber) {

            const dur = this.#player.getState('media.duration');

            const nodes = this.#chapters.map(({ start }, index) => {
                const next = this.#chapters[index + 1]?.start ?? dur;
                return {
                    className: 'vip-chapter-scrubber-item',
                    style: `left: ${start / dur * 100}%; width: ${(next - start) / dur * 100}%;`,
                    _nodes: [{ className: 'vip-chapter-scrubber-item-inner' }]
                };
            });

            this.#scrubber.replaceNode('scrubberInner', {
                _ref: 'scrubberInner',
                className: 'vip-chapter-scrubber-inner',
                _nodes: nodes
            });
        }

        if (this.#config.showInController) this.#onTimeUpdate();

    };

    /**
     * Called when the user hovers on the scrubber tooltip, so we can display the chapter title.
     * @param {number} percent  The fractional position on the timeline (0..1).
     * @listens module:src/controller/ScrubberTooltip#scrubber/tooltip/show
     * @listens module:src/controller/ScrubberTooltip#scrubber/tooltip/visible
     * @listens module:src/controller/ScrubberTooltip#scrubber/tooltip/move
     */
    #onToolTip = ({ percent }) => {

        const duration = this.#player.getState('media.duration'),
              chapter = this.#chapters.filter(({ start }) => start <= duration * percent / 100).slice(-1)[0],
              lang = this.#player.getConfig('locale.lang');

        if (typeof percent === 'undefined') {
            this.#tooltipContainer.chapterTooltip.nodeValue = '-';
        } else if (chapter) {
            const title = isObject(chapter.title) ? chapter.title[lang] || chapter.title[Object.keys(chapter.title)[0]] : chapter.title;
            this.#tooltipContainer.chapterTooltip.nodeValue = title;
        }
    };

    /**
     * Called periodically by "media/timeupdate" to highlight the current chapter in the controller
     * and optionally enable/disable next/prev buttons.
     * @listens module:src/core/Media#media/timeupdate
     */
    #onTimeUpdate = () => {

        const currentTime = this.#player.getState('media.currentTime');

        this.#chapter = this.#chapters.length - 1 - this.#chapters.slice().reverse().findIndex(({ start }) => start <= currentTime);

        const chapter = this.#chapters[this.#chapter],
              lang = this.#player.getConfig('locale.lang'),
              chapterText = isObject(chapter.title) ? chapter.title[lang] || chapter.title[Object.keys(chapter.title)[0]] : chapter.title;

        this.#controller.navPrev.disabled = currentTime < 3;
        this.#controller.navNext.disabled = this.#chapter >= this.#chapters.length - 1;

        if (!this.#config.showInController || this.#controller.wrapperText.nodeValue === chapterText) return;

        this.#controller.wrapperText.nodeValue = chapterText;
        this.#controller.textWrapper.setAttribute('aria-label', `${this.#player.locale.t('misc.chapter')}: ${chapterText}`);

    };

    /**
     * Called when the user clicks the "prev" or "next" button to switch chapters.
     * @param {Event} event  The click event.
     */
    #onSwitchChapter = ({ target }) => {

        const currentTime = this.#player.getState('media.currentTime');
        this.#chapter += target === this.#controller.navNext ? 1 : this.#chapters[this.#chapter].start > currentTime - 2 ? -1 : 0;

        if (this.#chapter < 0) this.#chapter = 0;
        if (this.#chapter > this.#chapters.length - 1) return;

        this.#player.media.seek(this.#chapters[this.#chapter].start);

    };

    /**
     * Enables the next / prev button functionality. This method listens to canplay events in order to restore a usable state again
     * when the player recovered from a media error (for example by loading another file).
     * @listens module:src/core/Media#media/canplay
     */
    #enable = () => {

        if (!this.#config.showInController) return;

        if (this.#chapter < this.#chapters.length - 1) this.#controller.navNext.disabled = false;
        if (this.#player.getState('media.currentTime') > 3) this.#controller.navPrev.disabled = false;

    };

    /**
     * Disables the next / prev button functionality. This method listens to media error events which cause the button to be disabled.
     * @listens module:src/core/Media#media/error
     * @listens module:src/core/Data#data/nomedia
     */
    #disable = () => {

        if (!this.#config.showInController) return;

        this.#controller.navNext.disabled = true;
        this.#controller.navPrev.disabled = true;

    };

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

        this.#tooltipContainer?.destroy();
        this.#controller?.destroy();
        this.#scrubber?.destroy();
        this.#player.unsubscribe(this.#subs);
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#scrubber = this.#controller = this.#tooltipContainer = null;

    }

}