import DomSmith from '../../lib/dom/DomSmith.js';
import Looper from '../../lib/util/Looper.js';
/**
* The Scrubber component provides interactive navigation through the media by allowing users to click or drag to seek.
* It includes a visual representation of both the buffered and played media ranges.
* The component adapts to live streams by hiding itself when seeking is not applicable.
* @exports module:src/controller/Scrubber
* @requires lib/dom/DomSmith
* @requires lib/util/Looper
* @author Frank Kudermann - alphanull
* @version 1.0.0
* @license MIT
*/
export default class Scrubber {
/**
* Holds the instance configuration for this component.
* @type {Object}
* @property {string} [placement='top'] Where to place the scrubber, either on 'top' or centered in the 'buttons' bar. The latter results in a more compact layout.
* @property {boolean} [continuousUpdate=false] Enables continuous seeking while dragging. Since this can be quite laggy on network connections, this setting is more suitable for playing local files.
* @property {boolean} [continuousUpdateBlob=true] Enables continuous seeking while dragging for blob media sources, even if `continuousUpdate` is false.
* @property {boolean} [showBuffered=true] If set, shows buffered ranges on the scrubber.
* @property {boolean} [showPlayed=true] If set, shows played ranges on the scrubber.
*/
#config = {
placement: 'top',
continuousUpdate: false,
continuousUpdateBlob: true,
showBuffered: true,
showPlayed: true
};
/**
* Reference to the main player instance.
* @type {module:src/core/Player}
*/
#player;
/**
* Reference to the parent instance.
* @type {module:src/controller/Controller}
*/
#parent;
/**
* 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 DomSmith Instance for the scrubber.
* @type {module:lib/dom/DomSmith}
*/
#dom;
/**
* Holds the canvas context of the buffered / played element.
* @type {CanvasRenderingContext2D}
*/
#canvasContext;
/**
* ResizeObserver instance used to track size changes of the scrubber element.
* @type {ResizeObserver}
*/
#resizeObserver;
/**
* Holds the actual width of the scrubber (in pixels).
* @type {number}
*/
#scrubberWidth = 0;
/**
* Holds the actual height of the scrubber (in pixels).
* @type {number}
*/
#scrubberHeight = 0;
/**
* Reference to the current media item.
* @type {module:src/core/Media~metaData}
*/
#current;
/**
* Indicates if active scrubbing takes place.
* @type {boolean}
*/
#isScrubbing = false;
/**
* Indicates if scrubber is disabled, if true no rendering takes place.
* @type {boolean}
*/
#isDisabled = false;
/**
* Render Loop Instance, used for updating the scrubber.
* @type {module:lib/util/Looper}
*/
#renderLoop;
/**
* Used to throttle seeking when seeking continuously, even with local files we don't want to seek 60x a second.
* @type {number}
*/
#lastSeekTime = 0;
/**
* Updated once when dragging starts, so we dont need to calculate this value again when moving the thumbnail.
* @type {number}
*/
#scrubOffsetLeft;
/**
* Saved thumbnail width for later positioning, updated when resize occurs.
* @type {number}
*/
#thumbWidth;
/**
* Holds the last saved time when user is navigating via keyboard.
* Used for updateing the scrubber after the user has released the key,
* and also prevwents event processing when the input just entered focus.
* @type {number}
*/
#keyDownBufferedTime = -1;
/**
* Creates an instance of the Scrubber component.
* @param {module:src/core/Player} player Reference to the VisionPlayer instance.
* @param {module:src/controller/Controller} parent Reference to the parent instance, in this case the controller.
* @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 }) {
this.#config = player.initConfig('scrubber', this.#config);
if (!this.#config) return [false];
this.#player = player;
this.#parent = parent;
this.#apiKey = apiKey;
this.#renderLoop = new Looper(this.#render);
this.#dom = new DomSmith({
_ref: 'wrapper',
className: 'vip-scrubber',
pointerdown: this.#onScrubberStart,
_nodes: [{
_ref: 'position',
className: 'vip-scrubber-position',
_nodes: [{
className: 'vip-scrubber-position-inner'
}]
}, {
_tag: 'input',
_ref: 'input',
className: 'vip-scrubber-input',
ariaLabel: this.#player.locale.t('misc.scrubber'),
'aria-valuetext': this.#player.locale.getLocalizedTime(0),
type: 'range',
$rangeFixDisable: true,
min: 0,
max: 1,
step: 3,
keydown: this.#onInputKeyDown,
keyup: this.#onInputKeyUp
}, {
_ref: 'buffered',
_tag: 'canvas',
className: 'vip-scrubber-buffered',
ariaHidden: true
}]
}, parent.getElement(this.#config.placement === 'buttons' ? 'center' : 'top'));
this.#canvasContext = this.#dom.buffered.getContext('2d');
this.#subscriptions = [
['data/nomedia', this.#disable],
['media/error', this.#disable],
['media/canplay', this.#enable],
['media/ready', this.#onMediaReady],
['media/play', this.#onUpdateStart],
['media/pause', this.#onUpdateStop],
['media/progress', this.#render],
['media/seeked', this.#render],
['media/timeupdate', this.#onTimeUpdate],
['dom/ready', this.#onDomReady],
['ui/show', this.#onUpdateStart],
['ui/hide', this.#onUpdateStop]
].map(([event, handler]) => this.#player.subscribe(event, handler));
}
/**
* Called when the entire player is ready. Sets up colors and implements resize.
* @listens module:src/core/Dom#dom/ready
*/
#onDomReady = () => {
// use Resize Oberserver, if supported
if (typeof ResizeObserver === 'undefined') {
this.#subscriptions.push(this.#player.subscribe('ui/resize', this.#resize));
this.#resize();
} else {
this.#resizeObserver = new ResizeObserver(this.#resize);
this.#resizeObserver.observe(this.#dom.wrapper);
}
};
/**
* Sets up the scrubber as soon as the meta data is available.
* If the current media happens to be a live stream, the scrubber is being hidden.
* @param {module:src/core/Media~metaData} metaData The currently selected metaData.
* @listens module:src/core/Media#media/ready
*/
#onMediaReady = metaData => {
this.#current = metaData;
if (this.#player.getState('media.liveStream')) {
this.#dom.unmount();
this.#parent.resize(); // TODO: should be changed later to resize observer on the controller itself
this.#disable();
} else {
this.#dom.input.setAttribute('max', this.#current.duration);
this.#dom.mount();
this.#parent.resize();
this.#enable();
}
};
/**
* Begins updating/rendering as soon as the UI is shown or media is played.
* @listens module:src/ui/UI#ui/show
* @listens module:src/core/Media#media/play
*/
#onUpdateStart = () => {
this.#renderLoop.stop();
if (this.#player.getState('media.paused') === false) this.#renderLoop.start();
};
/**
* Stops updating/rendering as soon as the UI is hidden or media is paused.
* @listens module:src/ui/UI#ui/hide
* @listens module:src/core/Media#media/pause
*/
#onUpdateStop = () => {
this.#renderLoop.stop();
};
/**
* Keydown handler on the input element, basically simulates keyboard interaction and updates the scubber thumbnail
* as well as the input element itself, so screenreaders are updated. Does not do anything if any non matching key is pressed.
* @param {KeyboardEvent} event The originating keyup event.
* @fires module:src/controller/Scrubber#scrubber/update
*/
#onInputKeyDown = event => {
const { duration } = this.#current,
currentTime = this.#keyDownBufferedTime > -1 ? this.#keyDownBufferedTime : this.#player.getState('media.currentTime');
let offset = 0;
switch (event.code) {
case 'ArrowDown':
case 'ArrowLeft':
offset = -3;
break;
case 'ArrowUp':
case 'ArrowRight':
offset = 3;
break;
case 'PageDown':
offset = -10;
break;
case 'PageUp':
offset = +10;
break;
case 'Home':
offset = -currentTime;
break;
case 'End':
offset = duration - currentTime;
break;
}
if (!offset) return;
// clamp values
const newTime = Math.max(0, Math.min(duration, currentTime + offset)),
percent = newTime / duration * 100;
if (this.#keyDownBufferedTime > -1) this.#player.publish('scrubber/update', { percent }, { async: false }, this.#apiKey);
this.#keyDownBufferedTime = newTime;
if (this.#config.continuousUpdate || this.#current.src.startsWith('blob:') && !this.#current.src.includes('mediasource') && this.#config.continuousUpdateBlob) {
this.#player.media.seek(percent * duration / 100);
}
this.#dom.input.value = newTime;
this.#dom.input.setAttribute('aria-valuetext', this.#player.locale.getLocalizedTime(newTime));
this.#renderLoop.stop();
this.#drawPosition(percent);
};
/**
* Keyup handler on the input element, seeks to the last buffered position.
* @fires module:src/controller/Scrubber#scrubber/end
*/
#onInputKeyUp = () => {
if (this.#keyDownBufferedTime === -1) return;
const { duration } = this.#current,
percent = this.#keyDownBufferedTime / duration * 100;
this.#keyDownBufferedTime = -1;
this.#renderLoop.start();
this.#player.media.seek(percent * duration / 100);
this.#player.publish('scrubber/end', { percent }, { async: false }, this.#apiKey);
};
/**
* Invoked when dragging starts. Can be either a pointerdown or keyboard event or when element has keyboard focus.
* @param {KeyboardEvent|PointerEvent} event The event that caused the drag start. Can be either a pointerdown or keyboard event.
* @fires module:src/controller/Scrubber#scrubber/start
*/
#onScrubberStart = ({ clientX, pointerId }) => {
if (this.#isDisabled) return;
this.#isScrubbing = true;
this.#scrubOffsetLeft = this.#dom.wrapper.getBoundingClientRect().left;
this.#dom.wrapper.setPointerCapture(pointerId);
this.#dom.addEvent('wrapper', 'pointermove', this.#onScrubberUpdate);
this.#dom.addEvent('wrapper', 'pointerup', this.#onScrubberEnd);
const relativeX = clientX - this.#scrubOffsetLeft,
percent = Math.max(0, Math.min(100, relativeX / this.#scrubberWidth * 100));
this.#player.publish('scrubber/start', { percent }, { async: false }, this.#apiKey);
this.#drawPosition(percent);
};
/**
* Invoked by the scrubber range slider when dragging is in progress or scrubber is triggered by keyboard.
* @param {InputEvent} event The input event that caused the drag start.
* @fires module:src/controller/Scrubber#scrubber/update
*/
#onScrubberUpdate = ({ clientX }) => {
const now = performance.now(),
relativeX = clientX - this.#scrubOffsetLeft,
percent = Math.max(0, Math.min(100, relativeX / this.#scrubberWidth * 100)),
shouldSeek = this.#config.continuousUpdate || this.#config.continuousUpdateBlob && this.#current.src.startsWith('blob:') && !this.#current.src.includes('mediasource');
// throttle continuous seeks to 100ms to avoid seek overload
if (shouldSeek && now - this.#lastSeekTime > 30) {
this.#player.media.seek(this.#current.duration * percent / 100);
this.#lastSeekTime = now;
}
this.#player.publish('scrubber/update', { percent }, { async: false }, this.#apiKey);
this.#drawPosition(percent);
};
/**
* Invoked when dragging the scrubber ends or a keyup event occurs.
* @param {KeyboardEvent|PointerEvent} event The event that caused the drag end. Can be either a pointerup or keyboard event.
* @fires module:src/controller/Scrubber#scrubber/end
*/
#onScrubberEnd = ({ clientX, pointerId }) => {
this.#isScrubbing = false;
this.#dom.wrapper.releasePointerCapture(pointerId);
this.#dom.removeEvent('wrapper', 'pointermove');
this.#dom.removeEvent('wrapper', 'pointerup');
const relativeX = clientX - this.#scrubOffsetLeft,
percent = Math.max(0, Math.min(100, relativeX / this.#scrubberWidth * 100));
this.#player.media.seek(this.#current.duration * percent / 100);
this.#player.publish('scrubber/end', { percent }, { async: false }, this.#apiKey);
this.#render();
};
/**
* Just updates the aria valuetext, but only if in focus.
* @listens module:src/core/Media#media/timeupdate
*/
#onTimeUpdate = () => {
this.#dom.input.value = this.#player.getState('media.currentTime');
this.#dom.input.setAttribute('aria-valuetext', this.#player.locale.getLocalizedTime(this.#dom.input.value));
};
/**
* Renders the scrubber progress by drawing the buffered/played ranges on the canvas.
* @param {null} event No Payload.
* @param {Event} topic The event topic ('media/seeked' or 'media/progress').
* @listens module:src/core/Media#media/progress
* @listens module:src/core/Media#media/seeked
*/
#render = (event, topic) => {
if (this.#isScrubbing || this.#isDisabled || !this.#player.getState('ui.visible') || this.#player.getState('media.liveStream')) return;
const played = this.#player.getState('media.played') ?? [],
buffered = this.#player.getState('media.buffered') ?? [],
paused = this.#player.getState('media.paused'),
duration = this.#current?.duration ?? this.#player.getState('media.duration'),
w = this.#scrubberWidth,
h = this.#scrubberHeight;
let x1, x2;
if (this.#config.showPlayed || this.#config.showBuffered) this.#canvasContext.clearRect(0, 0, w, h);
if (this.#config.showPlayed) {
// show played ranges
this.#canvasContext.fillStyle = 'rgba(150, 150, 150, 0.34)';
let j = played.length;
while ((j -= 1) > -1) {
x1 = Math.round(played.start(j) / duration * w);
x2 = Math.round(played.end(j) / duration * w);
this.#canvasContext.fillRect(x1, 0, x2 - x1, h);
}
}
if (this.#config.showBuffered) {
// show buffered ranges
this.#canvasContext.fillStyle = 'rgba(100, 100, 100, 0.25)';
let i = buffered.length;
while ((i -= 1) > -1) {
x1 = Math.round(buffered.start(i) / duration * w);
x2 = Math.round(buffered.end(i) / duration * w);
this.#canvasContext.fillRect(x1, 0, x2 - x1, h);
}
}
if (paused && topic !== 'media/seeked') return;
this.#drawPosition();
};
/**
* Positions the scrubber 'thumb' in the range input by setting the current time.
* @param {number} [percent] The scrubber position in percent. If not defined value will be calculated from currentTime and duration.
*/
#drawPosition(percent) {
const current = this.#player.getState('media.currentTime'),
duration = this.#current?.duration ?? this.#player.getState('media.duration'),
perc = typeof percent === 'undefined' ? current / duration : percent / 100;
this.#dom.position.style.transform = `translateX(${(this.#scrubberWidth - this.#thumbWidth) * perc}px)`;
}
/**
* Invoked when window resizes. Sets the scrubber width value accordingly.
* @param {ResizeObserverEntry[]} [entries] The entries if using ResizeObserver, otherwise `undefined`.
*/
#resize = entries => {
const rect = entries?.[0]?.contentRect ?? {},
height = rect.height ?? this.#dom.wrapper.clientHeight,
width = rect.width ?? this.#dom.wrapper.clientWidth;
this.#thumbWidth = this.#dom.position.clientWidth;
this.#dom.buffered.height = this.#scrubberHeight = height;
this.#dom.buffered.width = this.#scrubberWidth = width;
this.#render();
};
/**
* This method enables the scrubber.
* @listens module:src/core/Media#media/canplay
*/
#enable = () => {
this.#onUpdateStart();
this.#dom.wrapper.classList.remove('is-disabled');
this.#isDisabled = this.#dom.input.disabled = false;
this.#drawPosition();
};
/**
* This method disables the scrubber, for example after an error occurred.
* @listens module:src/core/Media#media/error
* @listens module:src/core/Data#data/nomedia
*/
#disable = () => {
this.#onUpdateStop();
this.#dom.wrapper.classList.add('is-disabled');
this.#isDisabled = this.#dom.input.disabled = true;
};
/**
* Returns the wrapper element for the scrubber.
* @returns {HTMLElement} The wrapper element of the scrubber.
*/
getElement() {
return this.#dom.wrapper;
}
/**
* This method removes all events, subscriptions and DOM nodes created by this component.
*/
destroy() {
this.#renderLoop.destroy();
this.#resizeObserver?.disconnect();
this.#dom.destroy();
this.#player.unsubscribe(this.#subscriptions);
this.#player = this.#dom = this.#parent = this.#resizeObserver = this.#canvasContext = this.#apiKey = this.#renderLoop = null;
}
}
/**
* This event is fired when user starts scrubbing.
* @event module:src/controller/Scrubber#scrubber/start
* @param {Object} position Scrubber Position.
* @param {number} percent Scrubber Position in percent, ranging from 0-100.
*/
/**
* This event is fired while user is scrubbing.
* @event module:src/controller/Scrubber#scrubber/update
* @param {Object} position Scrubber Position.
* @param {number} percent Scrubber Position in percent, ranging from 0-100.
*/
/**
* This event is fired when user stops scrubbing.
* @event module:src/controller/Scrubber#scrubber/end
* @param {Object} position Scrubber Position.
* @param {number} percent Scrubber Position in percent, ranging from 0-100.
*/