/** * 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 = ' 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'); } }