Files
hstream/resources/js/player/player-core.js
T
2026-07-29 22:12:51 +02:00

1435 lines
52 KiB
JavaScript

/**
* 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 = '<i class="fa-solid fa-play" style="margin-left:3px"></i>';
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 = '<div class="hstream-player__spinner"></div>';
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.container.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 = `<i class="${iconClass}"></i>`;
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 = `<span>${label} <span class="hstream-player__menu-value">${value}</span></span>`;
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 = `<span>Ambient Mode <span class="hstream-player__menu-value">${this._ambientMode ? 'On' : 'Off'}</span></span>`;
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 = `<span>${this._qualityLabel(q)}</span><span class="hstream-player__menu-item-radio"></span>`;
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 = `<span>${this._speedLabel(speed)}</span><span class="hstream-player__menu-item-radio"></span>`;
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 = '<span>Off</span><span class="hstream-player__menu-item-radio"></span>';
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.setAttribute('data-srclang', track.srclang);
item.innerHTML = `<span>${track.label}</span><span class="hstream-player__menu-item-radio"></span>`;
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 = `<i class="fa-solid fa-chevron-left"></i> ${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 = '<i class="fa-solid fa-pause"></i>';
this._playBtn.setAttribute('aria-label', 'Pause');
this._playOverlay.classList.remove('hstream-player__play-overlay--visible');
this._startAmbientLoop();
});
this.video.addEventListener('pause', () => {
this._playBtn.innerHTML = '<i class="fa-solid fa-play"></i>';
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 = '<i class="fa-solid fa-rotate-right"></i>';
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 = '<i class="fa-solid fa-volume-xmark"></i>';
this._muteBtn.setAttribute('aria-label', 'Unmute');
} else if (vol < 0.5) {
this._muteBtn.innerHTML = '<i class="fa-solid fa-volume-low"></i>';
this._muteBtn.setAttribute('aria-label', 'Mute');
} else {
this._muteBtn.innerHTML = '<i class="fa-solid fa-volume-high"></i>';
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 = '<i class="fa-solid fa-compress"></i>';
this._fullscreenBtn.setAttribute('aria-label', 'Exit fullscreen');
} else {
this._fullscreenBtn.innerHTML = '<i class="fa-solid fa-expand"></i>';
this._fullscreenBtn.setAttribute('aria-label', 'Fullscreen');
}
}
toggleCaptions() {
this._captionsActive = !this._captionsActive;
if (this._captionsActive) {
this._captionsBtn.classList.add('hstream-player__button--active');
} else {
this._captionsBtn.classList.remove('hstream-player__button--active');
}
var libassParent = document.querySelector('.libassjs-canvas-parent');
if (libassParent) {
libassParent.style.visibility = this._captionsActive ? 'visible' : 'hidden';
} else if (this._captionsActive && this._onLanguageChange) {
this._onLanguageChange(this._captionLanguage);
}
localStorage.setItem('hstreamCaptions', this._captionsActive.toString());
this._updateCaptionsPanel();
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);
}
this._updateCaptionsPanel();
}
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');
}
});
}
}
_updateCaptionsPanel() {
var captionsPanel = this._menuContainer.querySelector('[data-panel="captions"]');
if (!captionsPanel) return;
captionsPanel.querySelectorAll('.hstream-player__menu-item').forEach(function (item) {
item.classList.remove('hstream-player__menu-item--checked');
item.setAttribute('aria-checked', 'false');
});
if (!this._captionsActive) {
var offItem = captionsPanel.querySelector('.hstream-player__menu-item:not([data-srclang])');
if (offItem) {
offItem.classList.add('hstream-player__menu-item--checked');
offItem.setAttribute('aria-checked', 'true');
}
} else {
var langItem = captionsPanel.querySelector('.hstream-player__menu-item[data-srclang="' + this._captionLanguage + '"]');
if (langItem) {
langItem.classList.add('hstream-player__menu-item--checked');
langItem.setAttribute('aria-checked', 'true');
}
}
var mainPanel = this._menuContainer.querySelector('[data-panel="main"]');
if (mainPanel) {
var items = mainPanel.querySelectorAll('.hstream-player__menu-item');
for (var i = 0; i < items.length; i++) {
var text = items[i].textContent.trim();
if (text.startsWith('Subtitles')) {
var valueEl = items[i].querySelector('.hstream-player__menu-value');
if (valueEl) valueEl.textContent = this._captionLabel();
break;
}
}
}
}
get quality() {
return this._quality;
}
get isFullscreen() {
return !!document.fullscreenElement;
}
setSubtitleInstance(instance) {
this.subtitleInstance = instance;
}
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;
}
}