Files
lan-play/web/vendor/controlbar/ControlBar.js
T
Ultradesu eb0d4fb1c5
Build and Publish / Build and Publish Docker Image (push) Successful in 10m8s
Init
2026-08-05 12:32:52 +01:00

1318 lines
46 KiB
JavaScript

/**
* ControlBar.js - Reusable dash.js video control bar
*
* A self-contained, self-generating control bar component for dash.js.
* Generates its own DOM structure inside a consumer-provided container element.
*
* Features: play/pause, seekbar (div-based with buffer + played overlay),
* volume, mute, fullscreen, bitrate/track/caption menus, thumbnail preview,
* playback rate controls, auto-hide on hover with configurable timeout.
*
* Requirements:
* - dash.js MediaPlayer instance (global `dashjs` must be available)
* - Bootstrap Icons CSS loaded in the page
* - controlbar.css loaded in the page
*
* Usage:
* import { ControlBar } from './ControlBar.js';
* const cb = new ControlBar(player, videoElement);
* cb.init(document.getElementById('my-container'));
* cb.enable();
*/
const HIDE_DELAY = 3000;
// ---- Inline helpers (replaces UIHelpers.js dependency) ----
function formatTime(seconds) {
if (isNaN(seconds) || !isFinite(seconds)) {
return '00:00';
}
const negative = seconds < 0;
seconds = Math.abs(Math.floor(seconds));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const pad = (n) => String(n).padStart(2, '0');
const prefix = negative ? '- ' : '';
if (h > 0) {
return `${prefix}${pad(h)}:${pad(m)}:${pad(s)}`;
}
return `${prefix}${pad(m)}:${pad(s)}`;
}
function createElement(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === 'className') {
el.className = value;
} else if (key === 'textContent') {
el.textContent = value;
} else if (key === 'innerHTML') {
el.innerHTML = value;
} else if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
for (const child of children) {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else if (child) {
el.appendChild(child);
}
}
return el;
}
export class ControlBar {
/**
* @param {object} player - A dash.js MediaPlayer instance
* @param {HTMLVideoElement} videoElement - The video element managed by the player
*/
constructor(player, videoElement) {
this.player = player;
this.video = videoElement;
// DOM references (populated by _buildDOM)
this.container = null; // the controlbar root element we create
this.wrapper = null; // the consumer-provided wrapper / container parent
this.playPauseBtn = null;
this.playPauseIcon = null;
this.seekBackBtn = null;
this.seekForwardBtn = null;
this.nextBtn = null;
this.timeDisplay = null;
this.durationDisplay = null;
this.seekbarContainer = null;
this.seekbarPlayed = null;
this.seekbarBuffer = null;
this.volumeSlider = null;
this.muteBtn = null;
this.muteIcon = null;
this.fullscreenBtn = null;
this.fullscreenIcon = null;
this.bitrateBtn = null;
this.bitrateMenu = null;
this.trackBtn = null;
this.trackMenu = null;
this.captionBtn = null;
this.captionMenu = null;
this.timeSeparator = null;
this.rateDownBtn = null;
this.rateUpBtn = null;
this.rateDisplay = null;
this.thumbnailContainer = null;
this.thumbnailElem = null;
this.thumbnailTimeLabel = null;
// State
this._seeking = false;
this._hideTimer = null;
this._enabled = false;
this._duration = 0;
this._isDynamic = false;
this._vodDuration = null;
this._vodOffset = 0;
this._lastVolume = 1;
this._isFullscreen = false;
this._activeMenu = null;
this._selectionHandler = null;
this._seekHandler = null;
this._nextHandler = null;
this._domListeners = [];
this._playerListeners = [];
// Bound handlers for cleanup
this._onMouseMove = this._onMouseMoveBound.bind(this);
this._onMouseLeave = this._onMouseLeaveBound.bind(this);
this._onFullscreenChange = this._onFullscreenChangeBound.bind(this);
this._onVideoClick = () => this._togglePlayPause();
}
/**
* Initialize the control bar: build DOM, inject into container, register events.
*
* @param {HTMLElement|string} wrapperElement - The wrapper element (or CSS selector)
* that contains the video element. The controlbar will be appended inside this
* wrapper. It should have `position: relative` so the controlbar overlays correctly.
*/
init(wrapperElement) {
if (typeof wrapperElement === 'string') {
this.wrapper = document.querySelector(wrapperElement);
} else {
this.wrapper = wrapperElement;
}
if (!this.wrapper) {
throw new Error('ControlBar: wrapper element not found');
}
this._buildDOM();
this.wrapper.appendChild(this.container);
this._attachDOMEvents();
this._attachPlayerEvents();
// Initial state
this.volumeSlider.value = 1;
this._updatePlayPauseIcon();
}
/**
* Enable the control bar (interactive).
*/
enable() {
this._enabled = true;
this.container.classList.remove('cb-disabled');
}
/**
* Disable the control bar (non-interactive, dimmed).
*/
disable() {
this._enabled = false;
this.container.classList.add('cb-disabled');
this._closeAllMenus();
}
/**
* Reset control bar state (call on new stream load).
*/
reset() {
this._seeking = false;
this._duration = 0;
this._isDynamic = false;
this.seekbarPlayed.style.width = '0%';
this.seekbarBuffer.style.width = '0%';
this.timeDisplay.textContent = '00:00';
this.durationDisplay.textContent = '00:00';
this.durationDisplay.classList.remove('cb-live-indicator', 'cb-at-live-edge');
if (this.timeSeparator) {
this.timeSeparator.classList.remove('cb-hidden-element');
}
this._updateRateDisplay(1);
this._closeAllMenus();
this._destroyMenus();
}
/**
* Programmatically set the control bar to muted or unmuted state.
* Updates slider and icon without touching the player (use syncMuteState for that).
* @param {boolean} muted
*/
setMuted(muted) {
if (muted) {
this._lastVolume = parseFloat(this.volumeSlider.value) || 1;
this.volumeSlider.value = 0;
} else {
this.volumeSlider.value = this._lastVolume || 1;
}
this.muteIcon.className = muted ? 'bi bi-volume-mute-fill' : 'bi bi-volume-up-fill';
}
/**
* Re-apply the control bar's current volume/mute state to the player.
* Call after a new source is attached to keep mute state in sync.
*/
syncMuteState() {
try {
const vol = parseFloat(this.volumeSlider.value);
const isMuted = vol === 0 || this.muteIcon.className === 'bi bi-volume-mute-fill';
this.player.setVolume(isMuted ? 0 : vol);
this.player.setMute(isMuted);
} catch (e) {
// Player not ready
}
}
refreshTracks() {
this._rebuildTrackMenu();
this._rebuildCaptionMenu();
}
setVodDuration(seconds, offset = 0) {
const duration = Number(seconds);
this._vodDuration = Number.isFinite(duration) && duration > 0 ? duration : null;
const start = Number(offset);
this._vodOffset = Number.isFinite(start) && start > 0 ? start : 0;
}
onSeekRequested(handler) {
this._seekHandler = typeof handler === 'function' ? handler : null;
}
onNextRequested(handler) {
this._nextHandler = typeof handler === 'function' ? handler : null;
}
setNextAvailable(available) {
this.nextBtn?.classList.toggle('cb-hidden-element', !available);
}
onTrackSelected(handler) {
this._selectionHandler = typeof handler === 'function' ? handler : null;
}
_notifyTrackSelected(type, track) {
if (this._selectionHandler) {
this._selectionHandler(type, track);
}
}
/**
* Destroy the control bar: remove all event listeners and remove DOM.
*/
destroy() {
this._detachDOMEvents();
this._detachPlayerEvents();
this._closeAllMenus();
this._destroyMenus();
clearTimeout(this._hideTimer);
this._setCloseButtonHidden(false);
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}
// ================================================================
// DOM generation
// ================================================================
_buildDOM() {
// Thumbnail preview
this.thumbnailElem = createElement('div', { className: 'cb-thumbnail-elem' });
this.thumbnailTimeLabel = createElement('div', { className: 'cb-thumbnail-time' });
this.thumbnailContainer = createElement('div', { className: 'cb-thumbnail-container cb-hidden-element' },
this.thumbnailElem,
this.thumbnailTimeLabel
);
// Seekbar
this.seekbarBuffer = createElement('div', { className: 'cb-seekbar-buffer', style: 'width: 0%' });
this.seekbarPlayed = createElement('div', { className: 'cb-seekbar-played', style: 'width: 0%' });
this.seekbarContainer = createElement('div', { className: 'cb-seekbar' },
this.seekbarBuffer,
this.seekbarPlayed
);
const seekbarRow = createElement('div', { className: 'cb-seekbar-row' }, this.seekbarContainer);
// Play / Pause
this.playPauseIcon = createElement('i', { className: 'bi bi-play-fill' });
this.playPauseBtn = createElement('button', { className: 'cb-btn', title: 'Play/Pause' }, this.playPauseIcon);
this.seekBackBtn = createElement('button', {
className: 'cb-btn cb-skip-btn', title: 'Back 15 seconds', textContent: '-15'
});
this.seekForwardBtn = createElement('button', {
className: 'cb-btn cb-skip-btn', title: 'Forward 15 seconds', textContent: '+15'
});
this.nextBtn = createElement('button', { className: 'cb-btn cb-hidden-element', title: 'Next file' },
createElement('i', { className: 'bi bi-skip-forward-fill' })
);
// Time display
this.timeDisplay = createElement('span', { className: 'cb-time', textContent: '00:00' });
this.timeSeparator = createElement('span', { className: 'cb-time cb-time-separator', textContent: '/' });
this.durationDisplay = createElement('span', { className: 'cb-time', textContent: '00:00' });
// Spacer
const spacer = createElement('div', { className: 'cb-spacer' });
// Volume / Mute
this.muteIcon = createElement('i', { className: 'bi bi-volume-up-fill' });
this.muteBtn = createElement('button', { className: 'cb-btn', title: 'Mute/Unmute' }, this.muteIcon);
this.volumeSlider = createElement('input', {
type: 'range',
className: 'cb-volume-slider',
min: '0',
max: '1',
step: '0.05',
value: '1'
});
const volumeGroup = createElement('div', { className: 'cb-volume-group' },
this.muteBtn,
this.volumeSlider
);
// Playback rate
this.rateDownBtn = createElement('button', { className: 'cb-btn cb-btn-sm', title: 'Decrease speed' },
createElement('i', { className: 'bi bi-dash' })
);
this.rateDisplay = createElement('span', { className: 'cb-rate-display', title: 'Reset to 1x', textContent: '1.00x' });
this.rateUpBtn = createElement('button', { className: 'cb-btn cb-btn-sm', title: 'Increase speed' },
createElement('i', { className: 'bi bi-plus' })
);
const rateGroup = createElement('div', { className: 'cb-rate-group' },
this.rateDownBtn,
this.rateDisplay,
this.rateUpBtn
);
// Bitrate menu
this.bitrateMenu = createElement('div', { className: 'cb-menu cb-hidden-element' });
this.bitrateBtn = createElement('button', { className: 'cb-btn cb-hidden-element', title: 'Quality' },
createElement('i', { className: 'bi bi-sliders' })
);
const bitrateAnchor = createElement('div', { className: 'cb-menu-anchor' },
this.bitrateBtn,
this.bitrateMenu
);
// Track menu
this.trackMenu = createElement('div', { className: 'cb-menu cb-hidden-element' });
this.trackBtn = createElement('button', { className: 'cb-btn cb-hidden-element', title: 'Tracks' },
createElement('i', { className: 'bi bi-music-note-list' })
);
const trackAnchor = createElement('div', { className: 'cb-menu-anchor' },
this.trackBtn,
this.trackMenu
);
// Caption menu
this.captionMenu = createElement('div', { className: 'cb-menu cb-hidden-element' });
this.captionBtn = createElement('button', { className: 'cb-btn cb-hidden-element', title: 'Captions' },
createElement('i', { className: 'bi bi-badge-cc' })
);
const captionAnchor = createElement('div', { className: 'cb-menu-anchor' },
this.captionBtn,
this.captionMenu
);
// Fullscreen
this.fullscreenIcon = createElement('i', { className: 'bi bi-fullscreen' });
this.fullscreenBtn = createElement('button', { className: 'cb-btn', title: 'Fullscreen' }, this.fullscreenIcon);
// Controls row
const controlsRow = createElement('div', { className: 'cb-controls-row' },
this.playPauseBtn,
this.seekBackBtn,
this.seekForwardBtn,
this.nextBtn,
this.timeDisplay,
this.timeSeparator,
this.durationDisplay,
spacer,
volumeGroup,
rateGroup,
bitrateAnchor,
trackAnchor,
captionAnchor,
this.fullscreenBtn
);
// Root container
this.container = createElement('div', { className: 'cb-controlbar cb-disabled' },
this.thumbnailContainer,
seekbarRow,
controlsRow
);
}
// ================================================================
// DOM event handlers
// ================================================================
_attachDOMEvents() {
this._listen(this.playPauseBtn, 'click', () => this._togglePlayPause());
this._listen(this.seekBackBtn, 'click', () => this._requestRelativeSeek(-15));
this._listen(this.seekForwardBtn, 'click', () => this._requestRelativeSeek(15));
this._listen(this.nextBtn, 'click', () => this._nextHandler?.());
this._listen(this.muteBtn, 'click', () => this._toggleMute());
this._listen(this.fullscreenBtn, 'click', () => this._toggleFullscreen());
this._listen(this.volumeSlider, 'input', () => this._onVolumeInput());
this._listen(this.video, 'click', this._onVideoClick);
// Live indicator click — seek to live edge
this._listen(this.durationDisplay, 'click', () => this._seekToLiveEdge());
// Playback rate controls
this._listen(this.rateDownBtn, 'click', () => this._changeRate(-0.25));
this._listen(this.rateUpBtn, 'click', () => this._changeRate(0.25));
this._listen(this.rateDisplay, 'click', () => this._resetRate());
// Seekbar mouse events
this._listen(this.seekbarContainer, 'mousedown', (e) => this._onSeekMouseDown(e));
this._listen(this.seekbarContainer, 'mousemove', (e) => this._onSeekMouseMove(e));
this._listen(this.seekbarContainer, 'mouseleave', () => this._onSeekMouseLeave());
this._listen(document, 'mousemove', (e) => this._onDocumentMouseMove(e));
this._listen(document, 'mouseup', (e) => this._onDocumentMouseUp(e));
// Touch events for seekbar
this._listen(this.seekbarContainer, 'touchstart', (e) => this._onSeekTouchStart(e), { passive: false });
this._listen(this.seekbarContainer, 'touchmove', (e) => this._onSeekTouchMove(e), { passive: false });
this._listen(this.seekbarContainer, 'touchend', (e) => this._onSeekTouchEnd(e));
// Auto-hide: show on mouse move over wrapper, hide after delay
this._listen(this.wrapper, 'mousemove', this._onMouseMove);
this._listen(this.wrapper, 'mouseleave', this._onMouseLeave);
this._listen(this.wrapper, 'touchstart', this._onMouseMove, { passive: true });
// Fullscreen change
this._listen(document, 'fullscreenchange', this._onFullscreenChange);
this._listen(document, 'webkitfullscreenchange', this._onFullscreenChange);
// Bitrate / track / caption buttons
this._listen(this.bitrateBtn, 'click', (e) => {
e.stopPropagation();
this._toggleMenu('bitrate');
});
this._listen(this.trackBtn, 'click', (e) => {
e.stopPropagation();
this._toggleMenu('track');
});
this._listen(this.captionBtn, 'click', (e) => {
e.stopPropagation();
this._toggleMenu('caption');
});
// Close menus on click outside
this._listen(document, 'click', () => this._closeAllMenus());
// Prevent control bar clicks from closing menus
this._listen(this.container, 'click', (e) => e.stopPropagation());
}
_detachDOMEvents() {
this._domListeners.forEach(({ target, type, handler, options }) => {
target.removeEventListener(type, handler, options);
});
this._domListeners = [];
}
_listen(target, type, handler, options) {
target.addEventListener(type, handler, options);
this._domListeners.push({ target, type, handler, options });
}
_attachPlayerEvents() {
const events = dashjs.MediaPlayer.events;
this._listenPlayer(events.PLAYBACK_STARTED, () => this._onPlaybackStarted());
this._listenPlayer(events.PLAYBACK_PAUSED, () => this._onPlaybackPaused());
this._listenPlayer(events.PLAYBACK_TIME_UPDATED, (e) => this._onTimeUpdate(e));
this._listenPlayer(events.STREAM_ACTIVATED, () => this._onStreamActivated());
this._listenPlayer(events.STREAM_TEARDOWN_COMPLETE, () => this._onStreamTeardown());
this._listenPlayer(events.BUFFER_LEVEL_UPDATED, (e) => this._onBufferLevelUpdated(e));
this._listenPlayer(events.TEXT_TRACKS_ADDED, (e) => this._onTextTracksAdded(e));
if (dashjs.Protection && dashjs.Protection.events) {
this._listenPlayer(dashjs.Protection.events.KEY_STATUSES_MAP_UPDATED, () => this._rebuildBitrateMenu());
}
}
_detachPlayerEvents() {
this._playerListeners.forEach(({ event, handler }) => this.player.off(event, handler));
this._playerListeners = [];
}
_listenPlayer(event, handler) {
this.player.on(event, handler);
this._playerListeners.push({ event, handler });
}
// ================================================================
// Play / Pause
// ================================================================
_togglePlayPause() {
if (!this._enabled) {
return;
}
if (this.player.isPaused()) {
this.player.play();
} else {
this.player.pause();
}
}
_updatePlayPauseIcon() {
if (!this.playPauseIcon) {
return;
}
try {
const isPaused = this.player.isPaused();
this.playPauseIcon.className = isPaused ? 'bi bi-play-fill' : 'bi bi-pause-fill';
} catch (e) {
// Player not ready yet — default to play icon
this.playPauseIcon.className = 'bi bi-play-fill';
}
}
_onPlaybackStarted() {
this._updatePlayPauseIcon();
this._startHideTimer();
}
_onPlaybackPaused() {
this._updatePlayPauseIcon();
this._showControlBar();
this._clearHideTimer();
}
// ================================================================
// Time / Duration
// ================================================================
_onTimeUpdate() {
if (this._seeking) {
return;
}
try {
const time = this._vodOffset + this._sessionTime();
const duration = this._vodDuration || this.player.duration();
this._duration = duration;
this._isDynamic = this.player.isDynamic() && !this._vodDuration;
if (this._isDynamic) {
// Show negative latency (e.g. -00:20) instead of DVR position
const liveLatency = this.player.getCurrentLiveLatency() || 0;
this.timeDisplay.textContent = `-${formatTime(Math.round(liveLatency))}`;
this.durationDisplay.innerHTML = '<i class="bi bi-circle-fill"></i> LIVE';
this.durationDisplay.classList.add('cb-live-indicator');
// Hide separator for live
if (this.timeSeparator) {
this.timeSeparator.classList.add('cb-hidden-element');
}
// Check if at live edge
const targetDelay = this.player.getTargetLiveDelay() || 0;
const atLiveEdge = liveLatency <= targetDelay + 1;
this.durationDisplay.classList.toggle('cb-at-live-edge', atLiveEdge);
} else {
this.timeDisplay.textContent = formatTime(time);
this.durationDisplay.textContent = formatTime(duration);
this.durationDisplay.classList.remove('cb-live-indicator', 'cb-at-live-edge');
// Show separator for VoD
if (this.timeSeparator) {
this.timeSeparator.classList.remove('cb-hidden-element');
}
}
// Update seekbar progress
if (duration > 0) {
const pct = (time / duration) * 100;
this.seekbarPlayed.style.width = `${Math.min(pct, 100)}%`;
}
// Sync rate display with actual playback rate
const actualRate = this.player.getPlaybackRate();
if (actualRate !== undefined && actualRate !== null) {
this._updateRateDisplay(actualRate);
}
} catch (err) {
// Player may not be ready
}
}
_seekToLiveEdge() {
if (!this._enabled || !this._isDynamic) {
return;
}
try {
this.player.seekToOriginalLive();
} catch (e) {
// Fallback: seek to duration (end of DVR window)
try {
this.player.seek(this.player.duration());
} catch (e2) {
// Player not ready
}
}
}
// ================================================================
// Playback Rate
// ================================================================
_changeRate(delta) {
if (!this._enabled) {
return;
}
try {
const current = this.player.getPlaybackRate() || 1;
const next = Math.min(4, Math.max(0.25, Math.round((current + delta) * 4) / 4));
this.player.setPlaybackRate(next);
this._updateRateDisplay(next);
} catch (e) {
// Player not ready
}
}
_resetRate() {
if (!this._enabled) {
return;
}
try {
this.player.setPlaybackRate(1);
this._updateRateDisplay(1);
} catch (e) {
// Player not ready
}
}
_updateRateDisplay(rate) {
if (this.rateDisplay) {
this.rateDisplay.textContent = `${rate.toFixed(2)}x`;
}
}
// ================================================================
// Seekbar
// ================================================================
_getSeekTime(clientX) {
const rect = this.seekbarContainer.getBoundingClientRect();
let pct = (clientX - rect.left) / rect.width;
pct = Math.max(0, Math.min(1, pct));
return pct * (this._duration || 0);
}
_currentAbsoluteTime() {
return this._vodOffset + this._sessionTime();
}
_sessionTime() {
const time = Number(this.video.currentTime);
return Number.isFinite(time) && time > 0 ? time : 0;
}
_requestRelativeSeek(delta) {
this._requestSeek(this._currentAbsoluteTime() + delta);
}
_requestSeek(time) {
const maximum = this._vodDuration || this.player.duration();
const target = Math.max(0, Math.min(time, Math.max(0, maximum - 0.1)));
if (this._seekHandler) {
this._seekHandler(target);
} else {
this.player.seek(Math.max(0, target - this._vodOffset));
}
}
_onSeekMouseDown(e) {
if (!this._enabled) {
return;
}
this._seeking = true;
e.preventDefault();
}
_onDocumentMouseMove(e) {
if (!this._seeking) {
return;
}
const time = this._getSeekTime(e.clientX);
const pct = (this._duration > 0) ? (time / this._duration) * 100 : 0;
this.seekbarPlayed.style.width = `${pct}%`;
this.timeDisplay.textContent = formatTime(time);
}
_onDocumentMouseUp(e) {
if (!this._seeking) {
return;
}
this._seeking = false;
const time = this._getSeekTime(e.clientX);
this._requestSeek(time);
}
_onSeekMouseMove(e) {
if (!this._enabled || !this._duration) {
return;
}
const time = this._getSeekTime(e.clientX);
this._showThumbnail(e.clientX, time);
}
_onSeekMouseLeave() {
this._hideThumbnail();
}
_onSeekTouchStart(e) {
if (!this._enabled) {
return;
}
this._seeking = true;
e.preventDefault();
}
_onSeekTouchMove(e) {
if (!this._seeking || !e.touches[0]) {
return;
}
e.preventDefault();
const time = this._getSeekTime(e.touches[0].clientX);
const pct = (this._duration > 0) ? (time / this._duration) * 100 : 0;
this.seekbarPlayed.style.width = `${pct}%`;
this.timeDisplay.textContent = formatTime(time);
}
_onSeekTouchEnd(e) {
if (!this._seeking) {
return;
}
this._seeking = false;
if (e.changedTouches && e.changedTouches[0]) {
const time = this._getSeekTime(e.changedTouches[0].clientX);
this._requestSeek(time);
}
}
// ================================================================
// Thumbnails
// ================================================================
_showThumbnail(clientX, time) {
if (!this.player.provideThumbnail) {
return;
}
this.player.provideThumbnail(time, (thumbnail) => {
if (!thumbnail || !thumbnail.url) {
this._hideThumbnail();
return;
}
const containerRect = this.seekbarContainer.getBoundingClientRect();
const wrapperRect = this.wrapper.getBoundingClientRect();
// Scale thumbnail
const maxHeight = wrapperRect.height * 0.15;
const scale = Math.min(maxHeight / thumbnail.height, 2);
const width = thumbnail.width * scale;
const height = thumbnail.height * scale;
this.thumbnailElem.style.width = `${thumbnail.width}px`;
this.thumbnailElem.style.height = `${thumbnail.height}px`;
this.thumbnailElem.style.background = `url("${thumbnail.url}") -${thumbnail.x}px -${thumbnail.y}px`;
this.thumbnailElem.style.backgroundSize = '';
this.thumbnailElem.style.transform = `scale(${scale})`;
// Position horizontally centered on mouse
let left = clientX - containerRect.left - width / 2;
left = Math.max(0, Math.min(left, containerRect.width - width));
this.thumbnailContainer.style.left = `${left}px`;
this.thumbnailContainer.style.bottom = `${containerRect.height + 10}px`;
this.thumbnailTimeLabel.textContent = formatTime(time);
this.thumbnailContainer.classList.remove('cb-hidden-element');
});
}
_hideThumbnail() {
if (this.thumbnailContainer) {
this.thumbnailContainer.classList.add('cb-hidden-element');
}
}
// ================================================================
// Buffer
// ================================================================
_onBufferLevelUpdated(e) {
if (!e || e.mediaType !== 'video' || !this._duration || this._isDynamic) {
return;
}
try {
const dashMetrics = this.player.getDashMetrics();
const bufferLevel = dashMetrics.getCurrentBufferLevel('video', true) || 0;
const time = this._vodOffset + this._sessionTime();
const bufferEnd = time + bufferLevel;
const pct = (bufferEnd / this._duration) * 100;
this.seekbarBuffer.style.width = `${Math.min(pct, 100)}%`;
} catch (err) {
// ignore
}
}
// ================================================================
// Volume / Mute
// ================================================================
_onVolumeInput() {
const val = parseFloat(this.volumeSlider.value);
try {
this.player.setVolume(val);
this.player.setMute(val === 0);
} catch (e) {
// Player not ready
}
this._updateMuteIcon();
}
_toggleMute() {
try {
if (this.player.isMuted()) {
this.player.setMute(false);
this.player.setVolume(this._lastVolume || 1);
this.volumeSlider.value = this._lastVolume || 1;
} else {
this._lastVolume = this.player.getVolume();
this.player.setMute(true);
this.volumeSlider.value = 0;
}
} catch (e) {
// Player not ready
}
this._updateMuteIcon();
}
_updateMuteIcon() {
try {
const muted = this.player.isMuted() || parseFloat(this.volumeSlider.value) === 0;
this.muteIcon.className = muted ? 'bi bi-volume-mute-fill' : 'bi bi-volume-up-fill';
} catch (e) {
// Player not ready — default to unmuted icon
this.muteIcon.className = 'bi bi-volume-up-fill';
}
}
// ================================================================
// Fullscreen
// ================================================================
_toggleFullscreen() {
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
if (this.wrapper.requestFullscreen) {
this.wrapper.requestFullscreen();
} else if (this.wrapper.webkitRequestFullscreen) {
this.wrapper.webkitRequestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
}
}
_onFullscreenChangeBound() {
this._isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
this.fullscreenIcon.className = this._isFullscreen
? 'bi bi-fullscreen-exit'
: 'bi bi-fullscreen';
this.wrapper.classList.toggle('cb-fullscreen', this._isFullscreen);
}
// ================================================================
// Auto-hide
// ================================================================
_onMouseMoveBound() {
this._showControlBar();
this._startHideTimer();
}
_onMouseLeaveBound() {
try {
if (!this.player.isPaused()) {
this._startHideTimer();
}
} catch (e) {
// Player not ready
}
}
_showControlBar() {
this.container.classList.remove('cb-hidden');
this._setCloseButtonHidden(false);
this.wrapper.style.cursor = '';
}
_hideControlBar() {
try {
if (this.player.isPaused() || this._seeking || this._activeMenu) {
return;
}
} catch (e) {
return;
}
this.container.classList.add('cb-hidden');
this._setCloseButtonHidden(true);
this.wrapper.style.cursor = 'none';
}
_setCloseButtonHidden(hidden) {
const playerShell = this.wrapper.closest('.player');
const closeButton = playerShell && playerShell.querySelector('.player-close');
const title = playerShell && playerShell.querySelector('.player-title');
if (closeButton) {
closeButton.classList.toggle('player-close-hidden', hidden);
}
if (title) {
title.classList.toggle('player-title-hidden', hidden);
}
}
_startHideTimer() {
this._clearHideTimer();
try {
if (this.player.isPaused()) {
return;
}
} catch (e) {
return;
}
this._hideTimer = setTimeout(() => this._hideControlBar(), HIDE_DELAY);
}
_clearHideTimer() {
if (this._hideTimer) {
clearTimeout(this._hideTimer);
this._hideTimer = null;
}
}
// ================================================================
// Stream events
// ================================================================
_onStreamActivated() {
this._rebuildBitrateMenu();
this._rebuildTrackMenu();
this._rebuildCaptionMenu();
// Update duration
try {
this._duration = this._vodDuration || this.player.duration();
this._isDynamic = this.player.isDynamic() && !this._vodDuration;
} catch (err) {
// ignore
}
}
_onStreamTeardown() {
this.reset();
}
_onTextTracksAdded() {
this._rebuildCaptionMenu();
}
// ================================================================
// Menus
// ================================================================
_toggleMenu(menuName) {
const menuEl = this._getMenuElement(menuName);
if (!menuEl) {
return;
}
if (this._activeMenu === menuName) {
this._closeAllMenus();
} else {
this._closeAllMenus();
menuEl.classList.remove('cb-hidden-element');
this._activeMenu = menuName;
}
}
_closeAllMenus() {
for (const name of ['bitrate', 'track', 'caption']) {
const menuEl = this._getMenuElement(name);
if (menuEl) {
menuEl.classList.add('cb-hidden-element');
}
}
this._activeMenu = null;
}
_getMenuElement(name) {
switch (name) {
case 'bitrate':
return this.bitrateMenu;
case 'track':
return this.trackMenu;
case 'caption':
return this.captionMenu;
}
return null;
}
_destroyMenus() {
if (this.bitrateMenu) {
this.bitrateMenu.innerHTML = '';
}
if (this.trackMenu) {
this.trackMenu.innerHTML = '';
}
if (this.captionMenu) {
this.captionMenu.innerHTML = '';
}
}
_rebuildBitrateMenu() {
if (!this.bitrateMenu) {
return;
}
this.bitrateMenu.innerHTML = '';
let hasItems = false;
for (const type of ['video', 'audio']) {
try {
const reps = this.player.getRepresentationsByType(type);
if (!reps || reps.length < 1) {
continue;
}
hasItems = true;
const title = createElement('div', { className: 'cb-menu-title' }, type.charAt(0).toUpperCase() + type.slice(1));
this.bitrateMenu.appendChild(title);
// Auto switch option
const autoItem = createElement('div', {
className: 'cb-menu-item cb-menu-item-selected',
textContent: 'Auto',
onClick: () => {
this.player.updateSettings({
streaming: { abr: { autoSwitchBitrate: { [type]: true } } }
});
this._rebuildBitrateMenu();
}
});
this.bitrateMenu.appendChild(autoItem);
const settings = this.player.getSettings();
const autoSwitch = settings?.streaming?.abr?.autoSwitchBitrate?.[type] !== false;
const currentRep = this.player.getCurrentRepresentationForType(type);
reps.forEach((rep, idx) => {
const bitrate = Math.round(rep.bandwidth / 1000);
let label = `${bitrate} kbps`;
if (rep.width && rep.height) {
label += ` (${rep.width}x${rep.height})`;
}
const isCurrent = !autoSwitch && currentRep && currentRep.id === rep.id;
const item = createElement('div', {
className: `cb-menu-item ${isCurrent ? 'cb-menu-item-selected' : ''}`,
textContent: label,
onClick: () => {
this.player.updateSettings({
streaming: { abr: { autoSwitchBitrate: { [type]: false } } }
});
this.player.setRepresentationForTypeByIndex(type, idx, false);
this._rebuildBitrateMenu();
}
});
this.bitrateMenu.appendChild(item);
});
// Update auto item class
if (!autoSwitch) {
autoItem.classList.remove('cb-menu-item-selected');
}
} catch (err) {
// ignore
}
}
// Show/hide button
if (this.bitrateBtn) {
this.bitrateBtn.classList.toggle('cb-hidden-element', !hasItems);
}
}
_rebuildTrackMenu() {
if (!this.trackMenu) {
return;
}
this.trackMenu.innerHTML = '';
let hasItems = false;
for (const type of ['audio', 'video']) {
try {
const tracks = this.player.getTracksFor(type);
if (!tracks || tracks.length < 1) {
continue;
}
hasItems = true;
const title = createElement('div', { className: 'cb-menu-title' }, type.charAt(0).toUpperCase() + type.slice(1));
this.trackMenu.appendChild(title);
const currentTrack = this.player.getCurrentTrackFor(type);
tracks.forEach((track) => {
let label = '';
// Prefer explicit label if available
if (track.labels && track.labels.length > 0) {
const textLabel = track.labels.find(l => l.text) || track.labels[0];
if (textLabel && textLabel.text) {
label = textLabel.text;
}
}
// Fall back to language, then to media type name
if (!label) {
label = track.lang || type.charAt(0).toUpperCase() + type.slice(1);
}
const isCurrent = currentTrack && this._isTracksEqual(currentTrack, track);
const item = createElement('div', {
className: `cb-menu-item ${isCurrent ? 'cb-menu-item-selected' : ''}`,
textContent: label,
onClick: () => {
this.player.setCurrentTrack(track);
this._notifyTrackSelected(type, track);
this._rebuildTrackMenu();
}
});
this.trackMenu.appendChild(item);
});
} catch (err) {
// ignore
}
}
if (this.trackBtn) {
this.trackBtn.classList.toggle('cb-hidden-element', !hasItems);
}
}
_isTracksEqual(t1, t2) {
try {
if (!t1 || !t2) {
return false;
}
return t1.id === t2.id
&& t1.lang === t2.lang
&& t1.viewpoint === t2.viewpoint
&& String(t1.roles || '') === String(t2.roles || '')
&& String(t1.accessibility || '') === String(t2.accessibility || '')
&& String(t1.audioChannelConfiguration || '') === String(t2.audioChannelConfiguration || '');
} catch (e) {
return false;
}
}
_formatChannels(track) {
if (!track.audioChannelConfiguration || !track.audioChannelConfiguration.length) {
return null;
}
const value = track.audioChannelConfiguration[0].value;
switch (value) {
case '1':
return 'mono';
case '2':
return 'stereo';
case '6':
return '5.1';
case '8':
return '7.1';
default:
return value ? `${value}ch` : null;
}
}
_rebuildCaptionMenu() {
if (!this.captionMenu) {
return;
}
this.captionMenu.innerHTML = '';
try {
// Prefer dash.js tracks. dash.js also exposes them as native TextTracks,
// but selecting those directly bypasses its text timeline controller.
const tracks = this.player.getTracksFor('text');
if (!tracks || tracks.length === 0) {
this._rebuildNativeCaptionMenu();
return;
}
const currentIdx = this.player.getCurrentTextTrackIndex();
// OFF option
const offItem = createElement('div', {
className: `cb-menu-item ${currentIdx === -1 ? 'cb-menu-item-selected' : ''}`,
textContent: 'Off',
onClick: () => {
this.player.setTextTrack(-1);
this._notifyTrackSelected('text', null);
this._rebuildCaptionMenu();
}
});
this.captionMenu.appendChild(offItem);
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
// Build label: prefer Label element text, fall back to lang
let label = '';
if (track.labels && track.labels.length > 0) {
const textLabel = track.labels.find(l => l.text);
if (textLabel) {
label = textLabel.text;
}
}
if (!label) {
label = track.lang || 'Und';
}
const isCurrent = currentIdx === i;
const item = createElement('div', {
className: `cb-menu-item ${isCurrent ? 'cb-menu-item-selected' : ''}`,
textContent: label,
onClick: () => {
this.player.setTextTrack(i);
this._notifyTrackSelected('text', track);
this._rebuildCaptionMenu();
}
});
this.captionMenu.appendChild(item);
}
if (this.captionBtn) {
this.captionBtn.classList.toggle('cb-hidden-element', tracks.length === 0);
}
} catch (err) {
if (this.captionBtn) {
this.captionBtn.classList.add('cb-hidden-element');
}
}
}
_rebuildNativeCaptionMenu() {
const tracks = Array.from(this.video.textTracks || []);
if (!tracks.length) {
this.captionBtn?.classList.add('cb-hidden-element');
return;
}
const currentIdx = tracks.findIndex(track => track.mode === 'showing');
const select = (selected) => {
tracks.forEach((track, index) => {
track.mode = index === selected ? 'showing' : 'disabled';
});
this._notifyTrackSelected('nativeText', selected >= 0 ? tracks[selected] : null);
this._rebuildCaptionMenu();
};
this.captionMenu.appendChild(createElement('div', {
className: `cb-menu-item ${currentIdx === -1 ? 'cb-menu-item-selected' : ''}`,
textContent: 'Off',
onClick: () => select(-1)
}));
tracks.forEach((track, index) => {
this.captionMenu.appendChild(createElement('div', {
className: `cb-menu-item ${currentIdx === index ? 'cb-menu-item-selected' : ''}`,
textContent: track.label || track.language || `Subtitle ${index + 1}`,
onClick: () => select(index)
}));
});
this.captionBtn?.classList.remove('cb-hidden-element');
}
}