/** * HStreamPlayer — Custom video player with DASH.js, SubtitlesOctopus, * ambient mode, engagement heatmap, VTT thumbnails, and mobile gestures. */ import { ThumbnailPreview } from './player-thumbnails'; import { buildServerMenu } from './player-server-select'; import { renderHeatmap, removeHeatmap } from './player-heatmap'; const SKIP_SECONDS = 10; const SAVE_INTERVAL_MS = 10000; const IDLE_TIMEOUT_MS = 1500; export class HStreamPlayer { constructor(options) { this.container = options.container; this.video = options.video; this.apiResponse = options.apiResponse; this.streamServer = options.streamServer; this.streamServers = options.streamServers || []; this.fallbackServers = options.fallbackServers || []; this.streamServerIndex = options.streamServerIndex || 0; this.av1Supported = options.av1Supported || false; this.dashSupported = options.dashSupported || false; this.poster = options.poster || ''; this.title = options.title || ''; this._data = options.data || []; this._subtitleTracks = options.subtitleTracks || []; this._volume = options.volume !== undefined ? options.volume : 0.5; this._muted = options.muted || false; this._captionsActive = options.captionsActive !== undefined ? options.captionsActive : true; this._captionLanguage = options.captionLanguage || 'en'; this._ambientMode = options.ambientMode !== undefined ? options.ambientMode : true; this._mobileFill = options.mobileFill !== undefined ? options.mobileFill : true; this._isMobile = options.isMobile || false; this._quality = this._resolveQuality(options.quality || 1080); this._lastTime = options.lastTime || 0; this._onEnded = options.onEnded || null; this._onTimeUpdate = options.onTimeUpdate || null; this._onQualityChange = options.onQualityChange || null; this._onVolumeChange = options.onVolumeChange || null; this._onCaptionsToggle = options.onCaptionsToggle || null; this._onLanguageChange = options.onLanguageChange || null; this._onServerChange = options.onServerChange || null; this.subtitleInstance = options.subtitleInstance || null; this.dash = null; this._ambientCanvas = document.getElementById('ambientVideo'); this._ambientCtx = this._ambientCanvas ? this._ambientCanvas.getContext('2d') : null; this._idleTimer = null; this._saveInterval = null; this._controlsHidden = false; this._userInteracted = false; this._suppressOverlay = false; this._menuOpen = false; this._currentMenuPanel = 'main'; this._thumbnailPreview = null; this._buildDOM(); this._setupVideo(); this._buildControls(); this._setupEvents(); this._setupKeyboard(); this._startIdleTimer(); this._startSaveInterval(); this._updateUI(); } // ================================================================ // DOM Construction // ================================================================ _buildDOM() { this.container.classList.add('hstream-player'); if (this._isMobile) { this.container.classList.add('hstream-player--mobile'); } this.container.setAttribute('data-hstream-player', ''); this.video.classList.add('hstream-player__video'); this.video.setAttribute('playsinline', ''); this.video.setAttribute('crossorigin', 'anonymous'); this.video.removeAttribute('controls'); this._posterEl = document.createElement('div'); this._posterEl.className = 'hstream-player__poster'; if (this.poster) { const img = document.createElement('img'); img.src = this.poster; img.alt = this.title; img.setAttribute('draggable', 'false'); this._posterEl.appendChild(img); } this.container.appendChild(this._posterEl); this._playOverlay = document.createElement('div'); this._playOverlay.className = 'hstream-player__play-overlay'; const playBtn = document.createElement('button'); playBtn.className = 'hstream-player__play-btn'; playBtn.setAttribute('aria-label', 'Play'); playBtn.innerHTML = ''; playBtn.addEventListener('click', (e) => { e.stopPropagation(); this.togglePlay(); }); this._playOverlay.appendChild(playBtn); this.container.appendChild(this._playOverlay); this._loadingEl = document.createElement('div'); this._loadingEl.className = 'hstream-player__loading'; this._loadingEl.innerHTML = '
'; this.container.appendChild(this._loadingEl); this._skipOverlay = document.createElement('div'); this._skipOverlay.className = 'hstream-player__skip-overlay'; this._skipOverlay.setAttribute('aria-hidden', 'true'); this.container.appendChild(this._skipOverlay); this._controlsEl = document.createElement('div'); this._controlsEl.className = 'hstream-player__controls'; this.container.appendChild(this._controlsEl); this._menuContainer = document.createElement('div'); this._menuContainer.className = 'hstream-player__menu-container'; this._menuContainer.setAttribute('role', 'menu'); } // ================================================================ // Controls Construction // ================================================================ _buildControls() { this._progressWrapper = document.createElement('div'); this._progressWrapper.className = 'hstream-player__progress-wrapper'; this._progressEl = document.createElement('div'); this._progressEl.className = 'hstream-player__progress'; this._progressBuffer = document.createElement('div'); this._progressBuffer.className = 'hstream-player__progress-buffer'; this._progressEl.appendChild(this._progressBuffer); this._progressFill = document.createElement('div'); this._progressFill.className = 'hstream-player__progress-fill'; this._progressEl.appendChild(this._progressFill); this._progressThumb = document.createElement('div'); this._progressThumb.className = 'hstream-player__progress-thumb'; this._progressEl.appendChild(this._progressThumb); this._progressWrapper.appendChild(this._progressEl); this._timeTooltip = document.createElement('div'); this._timeTooltip.className = 'hstream-player__time-tooltip'; this._timeTooltip.setAttribute('aria-hidden', 'true'); this._progressWrapper.appendChild(this._timeTooltip); this._controlsEl.appendChild(this._progressWrapper); // Play/Pause button this._playBtn = this._createButton('play', 'fa-solid fa-play', 'Play'); this._playBtn.setAttribute('data-action', 'play'); this._playBtn.addEventListener('click', (e) => { e.stopPropagation(); this.togglePlay(); }); this._controlsEl.appendChild(this._playBtn); // Volume this._volumeWrapper = document.createElement('div'); this._volumeWrapper.className = 'hstream-player__volume-wrapper'; this._muteBtn = this._createButton('mute', 'fa-solid fa-volume-high', 'Mute'); this._muteBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleMute(); }); this._volumeWrapper.appendChild(this._muteBtn); this._volumeSliderContainer = document.createElement('div'); this._volumeSliderContainer.className = 'hstream-player__volume-slider-container'; this._volumeSlider = document.createElement('input'); this._volumeSlider.type = 'range'; this._volumeSlider.className = 'hstream-player__volume-slider'; this._volumeSlider.min = '0'; this._volumeSlider.max = '1'; this._volumeSlider.step = '0.05'; this._volumeSlider.value = this._muted ? 0 : this._volume; this._volumeSlider.addEventListener('input', (e) => { const val = parseFloat(e.target.value); this._updateVolumeSliderTrack(val); this.setVolume(val); }); this._volumeSlider.addEventListener('click', (e) => e.stopPropagation()); this._volumeSliderContainer.appendChild(this._volumeSlider); this._volumeSlider.style.setProperty('--volume-pct', (this._volumeSlider.value * 100) + '%'); this._volumeWrapper.appendChild(this._volumeSliderContainer); this._controlsEl.appendChild(this._volumeWrapper); // Time display this._timeDisplay = document.createElement('span'); this._timeDisplay.className = 'hstream-player__time-display'; this._timeDisplay.textContent = '0:00'; this._controlsEl.appendChild(this._timeDisplay); const sep = document.createElement('span'); sep.className = 'hstream-player__time-display hstream-player__time-separator'; sep.textContent = '/'; this._controlsEl.appendChild(sep); this._durationDisplay = document.createElement('span'); this._durationDisplay.className = 'hstream-player__time-display'; this._durationDisplay.textContent = '0:00'; this._controlsEl.appendChild(this._durationDisplay); // Spacer const spacer = document.createElement('div'); spacer.className = 'hstream-player__spacer'; this._controlsEl.appendChild(spacer); // Captions toggle this._captionsBtn = this._createButton('captions', 'fa-solid fa-closed-captioning', 'Subtitles'); this._captionsBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleCaptions(); }); this._captionsBtn.style.display = 'none'; this._controlsEl.appendChild(this._captionsBtn); // Settings button this._settingsBtn = this._createButton('settings', 'fa-solid fa-gear', 'Settings'); this._settingsBtn.setAttribute('data-action', 'settings'); this._settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); this._toggleMenu(); }); this._controlsEl.appendChild(this._settingsBtn); // Fullscreen button this._fullscreenBtn = this._createButton('fullscreen', 'fa-solid fa-expand', 'Fullscreen'); this._fullscreenBtn.setAttribute('data-action', 'fullscreen'); this._fullscreenBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleFullscreen(); }); this._controlsEl.appendChild(this._fullscreenBtn); // Build settings menu this._buildSettingsMenu(); this._controlsEl.appendChild(this._menuContainer); } _createButton(action, iconClass, label) { const btn = document.createElement('button'); btn.className = 'hstream-player__button'; btn.type = 'button'; btn.setAttribute('aria-label', label); btn.setAttribute('data-action', action); btn.innerHTML = ``; btn.title = label; return btn; } _buildSettingsMenu() { const mainPanel = document.createElement('div'); mainPanel.className = 'hstream-player__menu-panel hstream-player__menu-panel--active'; mainPanel.setAttribute('data-panel', 'main'); const addItem = (label, value, panelId, extraClass) => { const item = document.createElement('button'); item.className = 'hstream-player__menu-item' + (extraClass ? ' ' + extraClass : ''); item.type = 'button'; item.setAttribute('role', 'menuitem'); item.innerHTML = `${label} ${value}`; if (panelId) { item.addEventListener('click', (e) => { e.stopPropagation(); this._showMenuPanel(panelId); }); } return item; }; mainPanel.appendChild(addItem('Quality', this._qualityLabel(this._quality), 'quality')); mainPanel.appendChild(addItem('Playback Speed', this._speedLabel(this.video.playbackRate), 'speed')); if (this._subtitleTracks.length > 1) { mainPanel.appendChild(addItem('Subtitles', this._captionLabel(), 'captions')); } const serverLabel = this.streamServerIndex < this.streamServers.length ? 'CDN' + (this.streamServerIndex + 1) : 'Fallback ' + (this.streamServerIndex - this.streamServers.length + 1); mainPanel.appendChild(addItem('Server', serverLabel, 'server')); const divider = document.createElement('div'); divider.className = 'hstream-player__menu-divider'; mainPanel.appendChild(divider); const ambientItem = document.createElement('button'); ambientItem.className = 'hstream-player__menu-item'; ambientItem.type = 'button'; ambientItem.id = 'ambient-mode-toggle-menu'; ambientItem.innerHTML = `Ambient Mode ${this._ambientMode ? 'On' : 'Off'}`; ambientItem.addEventListener('click', (e) => { e.stopPropagation(); this.toggleAmbientMode(); }); mainPanel.appendChild(ambientItem); this._menuContainer.appendChild(mainPanel); this._buildQualityPanel(); this._buildSpeedPanel(); this._buildCaptionsPanel(); const serverPanel = buildServerMenu( this.streamServers, this.fallbackServers, this.streamServerIndex, (index) => { this._closeMenu(); if (this._onServerChange) { this._onServerChange(index); } }); this._menuContainer.appendChild(serverPanel); } _buildQualityPanel() { const panel = document.createElement('div'); panel.className = 'hstream-player__menu-panel'; panel.setAttribute('data-panel', 'quality'); const backBtn = this._createMenuBack('Quality'); backBtn.addEventListener('click', (e) => { e.stopPropagation(); this._showMenuPanel('main'); }); panel.appendChild(backBtn); const divider = document.createElement('div'); divider.className = 'hstream-player__menu-divider'; panel.appendChild(divider); const qualities = []; for (const src of this._data) { if (!qualities.includes(src.size)) { qualities.push(src.size); } } qualities.sort((a, b) => a - b); for (const q of qualities) { const item = document.createElement('button'); item.className = 'hstream-player__menu-item'; if (q === this._quality) { item.classList.add('hstream-player__menu-item--checked'); item.setAttribute('aria-checked', 'true'); } else { item.setAttribute('aria-checked', 'false'); } item.type = 'button'; item.setAttribute('role', 'menuitemradio'); item.innerHTML = `${this._qualityLabel(q)}`; item.addEventListener('click', (e) => { e.stopPropagation(); this.setQuality(q); this._closeMenu(); }); panel.appendChild(item); } this._menuContainer.appendChild(panel); } _buildSpeedPanel() { const panel = document.createElement('div'); panel.className = 'hstream-player__menu-panel'; panel.setAttribute('data-panel', 'speed'); const backBtn = this._createMenuBack('Playback Speed'); backBtn.addEventListener('click', (e) => { e.stopPropagation(); this._showMenuPanel('main'); }); panel.appendChild(backBtn); const divider = document.createElement('div'); divider.className = 'hstream-player__menu-divider'; panel.appendChild(divider); const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; for (const speed of speeds) { const item = document.createElement('button'); item.className = 'hstream-player__menu-item'; if (speed === this.video.playbackRate) { item.classList.add('hstream-player__menu-item--checked'); item.setAttribute('aria-checked', 'true'); } else { item.setAttribute('aria-checked', 'false'); } item.type = 'button'; item.setAttribute('role', 'menuitemradio'); item.innerHTML = `${this._speedLabel(speed)}`; item.addEventListener('click', (e) => { e.stopPropagation(); this.video.playbackRate = speed; this._updateSpeedMenus(); this._closeMenu(); }); panel.appendChild(item); } this._menuContainer.appendChild(panel); } _buildCaptionsPanel() { const panel = document.createElement('div'); panel.className = 'hstream-player__menu-panel'; panel.setAttribute('data-panel', 'captions'); const backBtn = this._createMenuBack('Subtitles'); backBtn.addEventListener('click', (e) => { e.stopPropagation(); this._showMenuPanel('main'); }); panel.appendChild(backBtn); const divider = document.createElement('div'); divider.className = 'hstream-player__menu-divider'; panel.appendChild(divider); const offItem = document.createElement('button'); offItem.className = 'hstream-player__menu-item'; if (!this._captionsActive) { offItem.classList.add('hstream-player__menu-item--checked'); } offItem.type = 'button'; offItem.setAttribute('role', 'menuitemradio'); offItem.innerHTML = 'Off'; offItem.addEventListener('click', (e) => { e.stopPropagation(); if (this._captionsActive) { this.toggleCaptions(); } this._closeMenu(); }); panel.appendChild(offItem); for (const track of this._subtitleTracks) { const item = document.createElement('button'); item.className = 'hstream-player__menu-item'; if (this._captionsActive && track.srclang === this._captionLanguage) { item.classList.add('hstream-player__menu-item--checked'); } item.type = 'button'; item.setAttribute('role', 'menuitemradio'); item.innerHTML = `${track.label}`; item.addEventListener('click', (e) => { e.stopPropagation(); this.setCaptionLanguage(track.srclang); this._closeMenu(); }); panel.appendChild(item); } this._menuContainer.appendChild(panel); } _createMenuBack(label) { const btn = document.createElement('button'); btn.className = 'hstream-player__menu-back'; btn.type = 'button'; btn.innerHTML = ` ${label}`; return btn; } _showMenuPanel(panelId) { this._menuContainer.querySelectorAll('.hstream-player__menu-panel').forEach(p => { p.classList.remove('hstream-player__menu-panel--active'); }); const panel = this._menuContainer.querySelector(`[data-panel="${panelId}"]`); if (panel) { panel.classList.add('hstream-player__menu-panel--active'); } this._currentMenuPanel = panelId; } _updateSpeedPanel() { const panel = this._menuContainer.querySelector('[data-panel="speed"]'); if (!panel) return; panel.querySelectorAll('.hstream-player__menu-item').forEach(item => { item.classList.remove('hstream-player__menu-item--checked'); }); } _updateSpeedMenus() { const label = this._speedLabel(this.video.playbackRate); const mainPanel = this._menuContainer.querySelector('[data-panel="main"]'); if (mainPanel) { const items = mainPanel.querySelectorAll('.hstream-player__menu-item'); for (const item of items) { const text = item.textContent.trim(); if (text.startsWith('Playback Speed')) { const valueEl = item.querySelector('.hstream-player__menu-value'); if (valueEl) valueEl.textContent = label; break; } } } const speedPanel = this._menuContainer.querySelector('[data-panel="speed"]'); if (speedPanel) { speedPanel.querySelectorAll('.hstream-player__menu-item').forEach(item => { const text = item.textContent.trim(); if (text === this._speedLabel(this.video.playbackRate)) { item.classList.add('hstream-player__menu-item--checked'); item.setAttribute('aria-checked', 'true'); } else { item.classList.remove('hstream-player__menu-item--checked'); item.setAttribute('aria-checked', 'false'); } }); } } // ================================================================ // Video Setup // ================================================================ _setupVideo() { this.video.volume = this._muted ? 0 : this._volume; this.video.muted = this._muted; if (this.poster) { this.video.poster = this.poster; } if (this._data.length > 0) { this._applySource(); } this.video.addEventListener('loadedmetadata', () => { this._durationDisplay.textContent = this._formatTime(this.video.duration); if (this._lastTime > 0) { this.video.currentTime = this._lastTime; } this._updateUI(); }); this.video.addEventListener('play', () => { this._userInteracted = true; this._hidePoster(); this._playBtn.innerHTML = ''; this._playBtn.setAttribute('aria-label', 'Pause'); this._playOverlay.classList.remove('hstream-player__play-overlay--visible'); this._startAmbientLoop(); }); this.video.addEventListener('pause', () => { this._playBtn.innerHTML = ''; this._playBtn.setAttribute('aria-label', 'Play'); if (!this._userInteracted && !this._suppressOverlay) { this._showPlayOverlay(); } if (this._ambientMode) { this._paintStaticAmbient(); } this._resetIdleTimer(); }); this.video.addEventListener('ended', () => { this._playBtn.innerHTML = ''; this._playBtn.setAttribute('aria-label', 'Replay'); if (!this._suppressOverlay) { this._showPlayOverlay(); } this._resetIdleTimer(); if (this._onEnded) { this._onEnded(); } }); this.video.addEventListener('timeupdate', () => { this._updateProgress(); if (this._onTimeUpdate) { this._onTimeUpdate(); } }); this.video.addEventListener('progress', () => { this._updateBuffer(); }); this.video.addEventListener('volumechange', () => { this._volume = this.video.volume; this._muted = this.video.muted; this._updateVolumeUI(); if (this._onVolumeChange) { this._onVolumeChange(); } }); this.video.addEventListener('waiting', () => { this._showLoading(); }); this.video.addEventListener('canplay', () => { this._hideLoading(); }); this.video.addEventListener('seeking', () => { this._showLoading(); }); this.video.addEventListener('seeked', () => { this._hideLoading(); if (this._ambientMode) { this._paintStaticAmbient(); } }); this.video.addEventListener('error', () => { this._hideLoading(); }); } _applySource() { const selectedSource = this._data.find(s => s.size === this._quality); if (!selectedSource && this._data.length > 0) { this._quality = this._data[0].size; } const source = selectedSource || this._data[0]; if (!source) return; if (source.mode === 'mpd' && this.dashSupported) { this._initDash(source.src); } else { this.video.src = source.src; if (source.type) { this.video.type = source.type; } } } _initDash(mpdUrl) { if (this.dash) { try { this.dash.destroy(); } catch (e) { /* ignore */ } this.dash = null; } if (typeof dashjs !== 'undefined') { this.dash = dashjs.MediaPlayer().create(); this.dash.initialize(this.video, mpdUrl, true); this.video.src = ''; } } // ================================================================ // Progress Bar // ================================================================ _setupProgressBar() { let dragging = false; const getTimeFromEvent = (e) => { const rect = this._progressEl.getBoundingClientRect(); const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left; const ratio = Math.max(0, Math.min(1, x / rect.width)); return ratio * this.video.duration; }; this._progressWrapper.addEventListener('mousedown', (e) => { dragging = true; this._progressWrapper.classList.add('hstream-player__progress-wrapper--active'); this.video.currentTime = getTimeFromEvent(e); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; this.video.currentTime = getTimeFromEvent(e); }); document.addEventListener('mouseup', () => { if (dragging) { dragging = false; this._progressWrapper.classList.remove('hstream-player__progress-wrapper--active'); } }); this._progressWrapper.addEventListener('touchstart', (e) => { dragging = true; this._progressWrapper.classList.add('hstream-player__progress-wrapper--active'); this.video.currentTime = getTimeFromEvent(e); }, { passive: true }); document.addEventListener('touchmove', (e) => { if (!dragging) return; this.video.currentTime = getTimeFromEvent(e); }, { passive: true }); document.addEventListener('touchend', () => { if (dragging) { dragging = false; this._progressWrapper.classList.remove('hstream-player__progress-wrapper--active'); } }); this._progressWrapper.addEventListener('mousemove', (e) => { const rect = this._progressEl.getBoundingClientRect(); const x = e.clientX - rect.left; const ratio = Math.max(0, Math.min(1, x / rect.width)); const time = ratio * this.video.duration; if (!isNaN(time)) { this._timeTooltip.textContent = this._formatTime(time); this._timeTooltip.classList.add('hstream-player__time-tooltip--visible'); this._timeTooltip.style.left = (ratio * 100) + '%'; } }); this._progressWrapper.addEventListener('mouseleave', () => { this._timeTooltip.classList.remove('hstream-player__time-tooltip--visible'); }); } _updateProgress() { if (!this.video.duration || isNaN(this.video.duration)) return; const ratio = this.video.currentTime / this.video.duration; this._progressFill.style.width = (ratio * 100) + '%'; this._progressThumb.style.left = (ratio * 100) + '%'; this._timeDisplay.textContent = this._formatTime(this.video.currentTime); } _updateBuffer() { if (this.video.buffered.length === 0) return; const bufferedEnd = this.video.buffered.end(this.video.buffered.length - 1); if (!this.video.duration || isNaN(this.video.duration)) return; const ratio = bufferedEnd / this.video.duration; this._progressBuffer.style.width = (ratio * 100) + '%'; } // ================================================================ // Volume UI // ================================================================ _updateVolumeUI() { const vol = this._muted ? 0 : this._volume; this._volumeSlider.value = vol; this._updateVolumeSliderTrack(vol); if (this._muted || vol === 0) { this._muteBtn.innerHTML = ''; this._muteBtn.setAttribute('aria-label', 'Unmute'); } else if (vol < 0.5) { this._muteBtn.innerHTML = ''; this._muteBtn.setAttribute('aria-label', 'Mute'); } else { this._muteBtn.innerHTML = ''; this._muteBtn.setAttribute('aria-label', 'Mute'); } } _updateVolumeSliderTrack(vol) { this._volumeSlider.style.setProperty('--volume-pct', (vol * 100) + '%'); } // ================================================================ // Event System // ================================================================ _setupEvents() { this._setupProgressBar(); this.container.addEventListener('click', (e) => { if (this._isMobile) return; if ( e.target.closest('.hstream-player__controls') || e.target.closest('.hstream-player__menu-container') || e.target.closest('.hstream-player__play-overlay') ) { return; } this.togglePlay(); }); this.container.addEventListener('dblclick', (e) => { if ( e.target.closest('.hstream-player__controls') || e.target.closest('.hstream-player__menu-container') ) { return; } this.toggleFullscreen(); }); this.container.addEventListener('mousemove', () => this._resetIdleTimer()); this.container.addEventListener('touchstart', () => this._resetIdleTimer(), { passive: true }); document.addEventListener('fullscreenchange', () => this._onFullscreenChange()); document.addEventListener('webkitfullscreenchange', () => this._onFullscreenChange()); document.addEventListener('click', (e) => { if (this._menuOpen && !this.container.contains(e.target)) { this._closeMenu(); } }); window.addEventListener('resize', () => { if (this._ambientMode) { this._updateAmbientCanvasSize(); if (this.video.paused) { this._paintStaticAmbient(); } } }); } _setupKeyboard() { this._keyHandler = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; switch (e.key.toLowerCase()) { case ' ': case 'k': e.preventDefault(); this.togglePlay(); break; case 'arrowleft': e.preventDefault(); this.video.currentTime = Math.max(0, this.video.currentTime - 5); break; case 'arrowright': e.preventDefault(); this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + 5); break; case 'arrowup': e.preventDefault(); this.setVolume(Math.min(1, this._volume + 0.05)); break; case 'arrowdown': e.preventDefault(); this.setVolume(Math.max(0, this._volume - 0.05)); break; case 'f': e.preventDefault(); this.toggleFullscreen(); break; case 'm': e.preventDefault(); this.toggleMute(); break; case 'c': e.preventDefault(); this.toggleCaptions(); break; } }; document.addEventListener('keydown', this._keyHandler); } // ================================================================ // Public API // ================================================================ play() { this.video.play().catch(() => {}); } pause() { this.video.pause(); } togglePlay() { if (this.video.paused) { this.play(); } else { this.pause(); } } stop() { this.video.pause(); this.video.currentTime = 0; } seek(time) { this.video.currentTime = Math.max(0, Math.min(this.video.duration || Infinity, time)); } forward() { this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + SKIP_SECONDS); } rewind() { this.video.currentTime = Math.max(0, this.video.currentTime - SKIP_SECONDS); } get currentTime() { return this.video.currentTime; } set currentTime(t) { this.video.currentTime = t; } get duration() { return this.video.duration; } get paused() { return this.video.paused; } get ended() { return this.video.ended; } get volume() { return this._volume; } get muted() { return this._muted; } setVolume(vol) { this._volume = Math.max(0, Math.min(1, vol)); this.video.volume = this._volume; if (this._volume > 0 && this._muted) { this._muted = false; this.video.muted = false; } this._saveLocalState(); } toggleMute() { this._muted = !this._muted; this.video.muted = this._muted; if (!this._muted && this._volume === 0) { this._volume = 0.5; this.video.volume = 0.5; } this._saveLocalState(); } toggleFullscreen() { if (document.fullscreenElement) { document.exitFullscreen(); } else { const el = this.container; if (el.requestFullscreen) { el.requestFullscreen(); } else if (el.webkitRequestFullscreen) { el.webkitRequestFullscreen(); } } } _onFullscreenChange() { const isFullscreen = !!document.fullscreenElement; if (isFullscreen) { this._fullscreenBtn.innerHTML = ''; this._fullscreenBtn.setAttribute('aria-label', 'Exit fullscreen'); } else { this._fullscreenBtn.innerHTML = ''; this._fullscreenBtn.setAttribute('aria-label', 'Fullscreen'); } } toggleCaptions() { this._captionsActive = !this._captionsActive; if (this.subtitleInstance && this.subtitleInstance.canvas) { this.subtitleInstance.canvas.style.visibility = this._captionsActive ? 'visible' : 'hidden'; } const libassParent = document.querySelector('.libassjs-canvas-parent'); if (libassParent) { libassParent.style.visibility = this._captionsActive ? 'visible' : 'hidden'; } if (this._captionsActive) { this._captionsBtn.classList.add('hstream-player__button--active'); } else { this._captionsBtn.classList.remove('hstream-player__button--active'); } localStorage.setItem('hstreamCaptions', this._captionsActive.toString()); if (this._onCaptionsToggle) { this._onCaptionsToggle(this._captionsActive); } } setCaptionLanguage(lang) { this._captionLanguage = lang; if (!this._captionsActive) { this._captionsActive = true; this._captionsBtn.classList.add('hstream-player__button--active'); localStorage.setItem('hstreamCaptions', 'true'); } if (this._onLanguageChange) { this._onLanguageChange(lang); } } get captionLanguage() { return this._captionLanguage; } get captionsActive() { return this._captionsActive; } setQuality(size) { if (size === this._quality) return; const prevTime = this.video.currentTime; const wasPlaying = !this.video.paused; this._suppressOverlay = true; this._quality = size; this._saveLocalState(); this._applySource(); if (prevTime > 0) { const restorePosition = () => { this._suppressOverlay = false; if (wasPlaying) { this.video.currentTime = prevTime; this.video.play().catch(() => {}); } else { this.video.currentTime = prevTime; } }; this.video.addEventListener('canplay', restorePosition, { once: true }); setTimeout(() => { this.video.removeEventListener('canplay', restorePosition); this._suppressOverlay = false; if (Math.abs(this.video.currentTime - prevTime) > 1 && prevTime > 0) { this.video.currentTime = prevTime; } }, 3000); } else { this._suppressOverlay = false; } if (this._onQualityChange) { this._onQualityChange(size); } this._updateQualityMenus(); } _updateQualityMenus() { const label = this._qualityLabel(this._quality); const mainPanel = this._menuContainer.querySelector('[data-panel="main"]'); if (mainPanel) { const items = mainPanel.querySelectorAll('.hstream-player__menu-item'); for (const item of items) { const text = item.textContent.trim(); if (text.startsWith('Quality')) { const valueEl = item.querySelector('.hstream-player__menu-value'); if (valueEl) valueEl.textContent = label; break; } } } const qualityPanel = this._menuContainer.querySelector('[data-panel="quality"]'); if (qualityPanel) { qualityPanel.querySelectorAll('.hstream-player__menu-item').forEach(item => { const text = item.textContent.trim(); if (text === this._qualityLabel(this._quality)) { item.classList.add('hstream-player__menu-item--checked'); item.setAttribute('aria-checked', 'true'); } else { item.classList.remove('hstream-player__menu-item--checked'); item.setAttribute('aria-checked', 'false'); } }); } } get quality() { return this._quality; } get isFullscreen() { return !!document.fullscreenElement; } toggleAmbientMode() { this._ambientMode = !this._ambientMode; localStorage.setItem('ambientMode', this._ambientMode.toString()); const menuItem = document.getElementById('ambient-mode-toggle-menu'); if (menuItem) { menuItem.querySelector('.hstream-player__menu-value').textContent = this._ambientMode ? 'On' : 'Off'; } if (this._ambientMode) { this._updateAmbientCanvasSize(); this._paintStaticAmbient(); if (!this.video.paused && !this.video.ended && localStorage.getItem('theme') !== 'light') { this._startAmbientLoop(); } } else { this._clearAmbientCanvas(); } } _clearAmbientCanvas() { if (!this._ambientCanvas || !this._ambientCtx) return; this._ambientCtx.clearRect(0, 0, this._ambientCanvas.width, this._ambientCanvas.height); } toggleMobileFill() { this._mobileFill = !this._mobileFill; this.video.style.objectFit = this._mobileFill ? 'cover' : 'contain'; } destroy() { this._clearIdleTimer(); if (this._saveInterval) { clearInterval(this._saveInterval); this._saveInterval = null; } if (this._keyHandler) { document.removeEventListener('keydown', this._keyHandler); this._keyHandler = null; } if (this.dash) { try { this.dash.destroy(); } catch (e) { /* ignore */ } this.dash = null; } if (this._thumbnailPreview) { this._thumbnailPreview.destroy(); this._thumbnailPreview = null; } removeHeatmap(); const dynSelectors = [ '.hstream-player__poster', '.hstream-player__play-overlay', '.hstream-player__loading', '.hstream-player__skip-overlay', '.hstream-player__controls', '.hstream-player__thumbnail-preview', ]; dynSelectors.forEach(sel => { const el = this.container.querySelector(sel); if (el) el.remove(); }); if (this.video && this.video.parentNode === this.container) { this.video.classList.remove('hstream-player__video'); this.video.removeAttribute('playsinline'); this.video.removeAttribute('crossorigin'); const cleanVideo = this.video.cloneNode(false); this.video.replaceWith(cleanVideo); } this.container.classList.remove('hstream-player', 'hstream-player--idle', 'hstream-player--mobile'); this.container.removeAttribute('data-hstream-player'); } // ================================================================ // Ambient Mode // ================================================================ _startAmbientLoop() { if (!this._ambientCanvas || !this._ambientCtx) return; if (!this._ambientMode) return; if (localStorage.getItem('theme') === 'light') return; this._updateAmbientCanvasSize(); const loop = () => { if (!this.video.paused && !this.video.ended && this._ambientMode) { if (localStorage.getItem('theme') !== 'light') { this._ambientCtx.drawImage( this.video, 0, 0, this._ambientCanvas.width, this._ambientCanvas.height ); } setTimeout(loop, 24); } }; loop(); } _paintStaticAmbient() { if (!this._ambientCanvas || !this._ambientCtx) return; if (!this._ambientMode) return; if (localStorage.getItem('theme') === 'light') return; this._updateAmbientCanvasSize(); this._ambientCtx.drawImage( this.video, 0, 0, this._ambientCanvas.width, this._ambientCanvas.height ); } _updateAmbientCanvasSize() { if (!this._ambientCanvas) return; this._ambientCanvas.width = this.video.offsetWidth; this._ambientCanvas.height = this.video.offsetHeight; } // ================================================================ // UI Helpers // ================================================================ _updateUI() { this._updateVolumeUI(); if (this._captionsActive) { this._captionsBtn.classList.add('hstream-player__button--active'); } if (this._subtitleTracks.length > 1) { this._captionsBtn.style.display = ''; } if (isNaN(this.video.duration)) { this._durationDisplay.textContent = '0:00'; } else { this._durationDisplay.textContent = this._formatTime(this.video.duration); } if (!this._userInteracted && this.video.currentTime === 0) { this._showPlayOverlay(); } } _hidePoster() { this._posterEl.classList.add('hstream-player__poster--hidden'); } _showPlayOverlay() { this._playOverlay.classList.add('hstream-player__play-overlay--visible'); } _showLoading() { this._loadingEl.classList.add('hstream-player__loading--visible'); } _hideLoading() { this._loadingEl.classList.remove('hstream-player__loading--visible'); } _toggleMenu() { if (this._menuOpen) { this._closeMenu(); } else { this._openMenu(); } } _openMenu() { this._menuOpen = true; this._showMenuPanel('main'); this._menuContainer.classList.add('hstream-player__menu-container--open'); } _closeMenu() { this._menuOpen = false; this._menuContainer.classList.remove('hstream-player__menu-container--open'); } // ================================================================ // Idle Timer (auto-hide controls) // ================================================================ _startIdleTimer() { this._resetIdleTimer(); } _resetIdleTimer() { this._clearIdleTimer(); this.container.classList.remove('hstream-player--idle'); this._controlsEl.classList.remove('hstream-player__controls--hidden'); this._idleTimer = setTimeout(() => { if (!this.video.paused && !this._menuOpen) { this.container.classList.add('hstream-player--idle'); } }, IDLE_TIMEOUT_MS); } _clearIdleTimer() { if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; } } // ================================================================ // Save Interval // ================================================================ _startSaveInterval() { this._saveInterval = setInterval(() => { if (!this.video.paused) { this._lastTime = this.video.currentTime; } }, SAVE_INTERVAL_MS); } getLastTime() { return this._lastTime; } _saveLocalState() { localStorage.setItem('hstreamVolume', this._volume.toString()); localStorage.setItem('hstreamMuted', this._muted.toString()); localStorage.setItem('hstreamQuality', this._quality.toString()); } _resolveQuality(preferredSize) { const sizes = this._data.map(s => s.size); if (sizes.includes(preferredSize)) { return preferredSize; } if (preferredSize === 2161) { if (sizes.includes(2160)) return 2160; if (sizes.includes(1080)) return 1080; if (sizes.includes(720)) return 720; } if (preferredSize === 1081) { if (sizes.includes(1080)) return 1080; if (sizes.includes(720)) return 720; } if (sizes.includes(720)) return 720; return sizes[0] || 720; } // ================================================================ // Thumbnails // ================================================================ initThumbnails(vttUrl) { if (!vttUrl) return; this._thumbnailPreview = new ThumbnailPreview(this._progressWrapper, this.video); this._thumbnailPreview.load(vttUrl); } initHeatmap(episodeId) { const video = this.video; if (video && video.duration && !isNaN(video.duration)) { renderHeatmap(episodeId, video.duration); } else if (video) { const onMeta = () => { video.removeEventListener('loadedmetadata', onMeta); if (video.duration && !isNaN(video.duration)) { renderHeatmap(episodeId, video.duration); } }; video.addEventListener('loadedmetadata', onMeta); } } // ================================================================ // Formatting // ================================================================ _formatTime(seconds) { if (isNaN(seconds) || !isFinite(seconds)) return '0:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) { return h + ':' + m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0'); } return m + ':' + s.toString().padStart(2, '0'); } _qualityLabel(size) { const labels = { 2161: '2160p48', 2160: '2160p', 1081: '1080p48', 1080: '1080p', 720: '720p', }; return labels[size] || size + 'p'; } _speedLabel(speed) { if (speed === 1) return 'Normal'; return speed + 'x'; } _captionLabel() { if (!this._captionsActive) return 'Off'; const track = this._subtitleTracks.find(t => t.srclang === this._captionLanguage); return track ? track.label : 'English'; } // ================================================================ // Expose transcript for menu entry point reuse // ================================================================ getVideoElement() { return this.video; } }