1392 lines
50 KiB
JavaScript
1392 lines
50 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._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 = `<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.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.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;
|
|
}
|
|
}
|