Skip to content

Source: src/ui/Time.js

import DomSmith from '../../lib/dom/DomSmith.js';
import Looper from '../../lib/util/Looper.js';
import convertTime from '../util/convertTime.js';

/**
 * The Time component displays the current, remaining, and total media time.
 * Users can click the time element to toggle between representations, including optional frame display and prefix indicators.
 * It dynamically updates based on playback state and disables itself during live streams or playback errors.
 * @exports module:src/ui/Time
 * @requires lib/dom/DomSmith
 * @requires lib/util/Looper
 * @requires src/util/convertTime
 * @author    Frank Kudermann - alphanull
 * @version   1.0.0
 * @license   MIT
 */
export default class Time {

    /**
     * Holds the *instance* configuration for this component.
     * @type     {Object}
     * @property {string}  [display="current"]  Initial time display mode, either "current" or "remaining".
     * @property {boolean} [showFrames=false]   Whether to display frames in the time string.
     */
    #config = {
        display: 'current',
        showFrames: false
    };

    /**
     * Reference to the main player instance.
     * @type {module:src/core/Player}
     */
    #player;

    /**
     * Reference to the DomSmith Instance for the time display.
     * @type {module:lib/dom/DomSmith}
     */
    #dom;

    /**
     * Render Loop Instance, used for updating the scrubber.
     * @type {module:lib/util/Looper}
     */
    #renderLoop;

    /**
     * Creates an instance of the Time component.
     * @param {module:src/core/Player}           player  Reference to the Visi#onPlayer instance.
     * @param {module:src/controller/Controller} parent  The parent container, in this case the controller.
     */
    constructor(player, parent) {

        this.#config = player.initConfig('time', this.#config);

        if (!this.#config) return [false];

        this.#player = player;
        this.#renderLoop = new Looper(this.#onTimeUpdate);

        this.#dom = new DomSmith({
            _ref: 'timeButton',
            _tag: 'button',
            className: `vip-controller-time ${this.#config.showFrames ? ' has-frames' : ''}`,
            'data-sort': 35,
            click: this.#onToggleTime,
            ariaLabel: `${this.#player.locale.t('misc.time')}: ${this.#player.locale.getLocalizedTime(0)}`,
            transitionend: e => {
                if (e.target === this.#dom.timeButton && !this.#dom.timeButton.classList.contains('hidden') && this.#dom.timeButton.style.cssText !== '') {
                    this.#dom.timeButton.style.cssText = '';
                }
            },
            _nodes: [{
                _tag: 'span',
                _ref: 'timePrimary',
                className: 'primary',
                ariaHidden: true,
                _nodes: [{
                    _ref: 'timePrimaryText',
                    _text: '00:00'
                }]
            }, {
                _tag: 'span',
                _ref: 'timeSeparator',
                className: 'separator',
                ariaHidden: true,
                _nodes: ['/']
            }, {
                _tag: 'span',
                _ref: 'timeSecondary',
                className: 'secondary',
                ariaHidden: true,
                _nodes: [{
                    _ref: 'timeSecondaryText',
                    _text: '00:00'
                }]
            }]
        }, parent.getElement('left'));

        this.#player.subscribe('media/ready', this.#onMediaReady);
        this.#player.subscribe('media/error', this.#disable);
        this.#player.subscribe('data/nomedia', this.#disable);

    }

    /**
     * Sets up the component as soon as the media is available. Uses a placeholder text if the stream is a Live Stream.
     * @listens module:src/core/Media#media/ready
     */
    #onMediaReady = () => {

        this.#player.unsubscribe('media/timeupdate', this.#onTimeUpdate);
        this.#player.unsubscribe('media/play', this.#onPlay);
        this.#player.unsubscribe('media/pause', this.#onPause);

        if (this.#player.getState('media.liveStream')) {
            this.#dom.timeButton.disabled = true;
            this.#dom.timeSeparator.textContent = '';
            this.#dom.timePrimaryText.nodeValue = this.#player.locale.t('misc.isLive');
            this.#dom.timeSecondaryText.nodeValue = '';
            return;
        }

        this.#player.subscribe('media/timeupdate', this.#onTimeUpdate);
        this.#dom.timeSeparator.textContent = '/';
        this.#dom.timeButton.disabled = false;

        const { h } = convertTime(this.#player.getState('media.duration'), this.#config.showFrames ? this.#player.getState('media.frameRate') : 0).smpte;

        this.#dom.timeButton.classList.toggle('has-hours', h > 0);
        this.#dom.timeButton.classList.toggle('has-hours-x2', h > 9);

        if (this.#config.showFrames) {
            // higher update frequency if frames should be displayed
            // timeupdate does not fire often enough for smooth display
            this.#player.subscribe('media/play', this.#onPlay);
            this.#player.subscribe('media/pause', this.#onPause);
        }

        this.#onTimeUpdate();

    };

    /**
     * Immediately updates time display when playback starts (and frames are shown).
     * @listens module:src/core/Media#media/play
     */
    #onPlay = () => {

        this.#renderLoop.start();

    };

    /**
     * Cancels frame-based updates when paused.
     * @listens module:src/core/Media#media/pause
     */
    #onPause = () => {

        this.#renderLoop.stop();

    };

    /**
     * Updates the time display based on the timeupdate event, or by using a requestAnimationFrame
     * if frames should be displayed as well (to get more smooth updates in this case).
     * @listens module:src/core/Media#media/timeupdate
     */
    #onTimeUpdate = () => {

        const timeText = this.#player.locale.t('misc.time'),
              remainingTime = this.#player.getState('media.remainingTime'),
              currentTime = this.#player.getState('media.currentTime'),
              duration = this.#player.getState('media.duration');

        const format = convTime => {
            const converted = convertTime(convTime, this.#config.showFrames ? this.#player.getState('media.frameRate') : 0),
                  hours = Math.round(duration / 3600),
                  hoursIndex = hours > 0 ? hours >= 10 ? 0 : 1 : 3,
                  lastIndex = this.#config.showFrames ? converted.string.length : converted.string.lastIndexOf(':');
            return converted.string.substr(hoursIndex, lastIndex - hoursIndex);
        };

        switch (this.#config.display) {
            case 'remaining':
                this.#dom.timePrimaryText.nodeValue = isNaN(remainingTime) ? '---' : format(remainingTime);
                this.#dom.timeButton.setAttribute('aria-label', `${timeText}: ${this.#player.locale.getLocalizedTime(remainingTime)}`);
                break;
            case 'current':
            default:
                this.#dom.timePrimaryText.nodeValue = isNaN(currentTime) ? '---' : format(currentTime);
                this.#dom.timeButton.setAttribute('aria-label', `${timeText}: ${this.#player.locale.getLocalizedTime(currentTime)}`);
                break;
        }

        this.#dom.timeSecondaryText.nodeValue = duration === Infinity || isNaN(duration) ? '---' : format(duration);

        if (this.#config.showFrames && !this.#player.getState('media.paused')) {
            this.#renderLoop.start();
        }

    };

    /**
     * Toggles time display mode between "current" and "remaining" after user klicked on the time display.
     */
    #onToggleTime = () => {

        this.#config.display = this.#config.display === 'remaining' ? 'current' : 'remaining';
        this.#onTimeUpdate();

    };

    /**
     * Enables the play time display. 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 = () => {

        this.#dom.timeButton.disabled = false;

    };

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

        this.#dom.timeButton.disabled = true;

    };

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

        this.#dom.destroy();
        this.#renderLoop.destroy();

        this.#player.unsubscribe('data/ready', this.onDataReady);
        this.#player.unsubscribe('data/error', this.#disable);
        this.#player.unsubscribe('media/ready', this.#onMediaReady);
        this.#player.unsubscribe('media/error', this.#disable);
        this.#player.unsubscribe('media/canplay', this.#enable);
        this.#player.unsubscribe('media/timeupdate', this.#onTimeUpdate);
        this.#player.unsubscribe('media/play', this.#onPlay);
        this.#player = this.#dom = null;

    }

}