Custom video player

This commit is contained in:
2026-07-26 22:26:37 +02:00
parent 7553b9f895
commit 2e3918def4
13 changed files with 3547 additions and 905 deletions
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
export function addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported) {
if (dashSupported) {
return addDashTracks(streamServer, apiResponse, av1Supported);
}
return addLegacyTracks(streamServer, apiResponse, av1Supported);
}
function addDashTracks(streamServer, apiResponse, av1Supported) {
var data = [];
// 720p
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/720/manifest.mpd',
size: 720,
mode: 'mpd',
});
if (av1Supported) {
// 1080p
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/1080/manifest.mpd',
size: 1080,
mode: 'mpd',
});
// 2160p
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/2160/manifest.mpd',
size: 2160,
mode: 'mpd',
});
if (apiResponse.interpolated == 1) {
// 1080p Interpolated
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/1080i/manifest.mpd',
size: 1081,
mode: 'mpd',
});
}
if (apiResponse.interpolated_uhd == 1) {
// 2160p Interpolated
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/2160i/manifest.mpd',
size: 2161,
mode: 'mpd',
});
}
}
return data;
}
function addLegacyTracks(streamServer, apiResponse, av1Supported) {
var data = [];
// 720p
data.push({
src: streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4',
type: 'video/mp4',
size: 720,
});
return data;
}
export function addSubtitleTracks(streamServer, apiResponse) {
var data = [];
// Default
data.push({
kind: 'captions',
label: 'English',
srclang: 'en',
src: '',
default: true,
});
for (var key in apiResponse.extra_subtitles) {
data.push({
kind: 'captions',
label: apiResponse.extra_subtitles[key] + ' (Auto Transl.)',
srclang: key,
src: '',
default: false,
});
}
return data;
}
+72
View File
@@ -0,0 +1,72 @@
// Engagement heatmap tracking
// Samples the user's current time while playing and sends batched segment data to the server.
// Only tracks segment >= 1 (excludes 0-10s).
// Only calls the endpoint when the user is logged in.
let engagementInterval;
let engagementSegments = new Set();
let engagementReportInterval;
const SEGMENT_DURATION = 10; // seconds per segment
const SAMPLE_INTERVAL = 5000; // sample every 5s
const REPORT_INTERVAL = 15000; // send batch every 15s
function isAuthenticated() {
const el = document.getElementById('auth_check');
return el && el.value === '1';
}
function sendEngagement(episodeId, segments) {
if (!isAuthenticated()) return;
window.axios.post('/player/engagement', {
episode_id: episodeId,
segments: segments,
}).catch(() => {
// Fire-and-forget: silently ignore network errors
});
}
export function startEngagementTracking(episodeId) {
engagementSegments.clear();
// Sample current time while playing
engagementInterval = setInterval(() => {
const video = document.querySelector('video');
if (!video || video.paused) return;
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
// Skip segment 0 (0-10s) — no need to track the very start
if (segment >= 1) {
engagementSegments.add(segment);
}
}, SAMPLE_INTERVAL);
// Batch report to server
engagementReportInterval = setInterval(() => {
if (engagementSegments.size === 0) return;
const segments = Array.from(engagementSegments);
engagementSegments.clear();
sendEngagement(episodeId, segments);
}, REPORT_INTERVAL);
// Flush remaining segments & cleanup on page unload
const cleanup = () => {
clearInterval(engagementInterval);
clearInterval(engagementReportInterval);
if (engagementSegments.size > 0) {
const segments = Array.from(engagementSegments);
engagementSegments.clear();
sendEngagement(episodeId, segments);
}
};
window.addEventListener('beforeunload', cleanup);
}
export function stopEngagementTracking() {
if (engagementInterval) clearInterval(engagementInterval);
if (engagementReportInterval) clearInterval(engagementReportInterval);
}
+214
View File
@@ -0,0 +1,214 @@
// Engagement heatmap display
// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track.
let heatmapContainer = null;
let heatmapCanvas = null;
let heatmapResizeObserver = null;
/**
* Fetch engagement data from the server and render the heatmap.
* @param {string} episodeId - The episode ID.
* @param {number} duration - Video duration in seconds.
*/
export async function renderHeatmap(episodeId, duration) {
try {
const response = await window.axios.get(`/player/engagement/${episodeId}`);
const data = response.data;
if (!data || Object.keys(data).length === 0) {
return;
}
drawHeatmapCurve(data, duration);
} catch (error) {
console.error('Failed to load engagement data:', error);
}
}
/**
* Draw a smooth area chart on a canvas element above the progress bar.
* @param {Object} data - Key-value map of segment -> watch_count.
* @param {number} duration - Video duration in seconds.
*/
function drawHeatmapCurve(data, duration) {
const SEGMENT_DURATION = 10;
const totalSegments = Math.ceil(duration / SEGMENT_DURATION);
// Build raw counts array, filling gaps with 0
const rawCounts = [];
for (let i = 0; i < totalSegments; i++) {
rawCounts.push(data[i] || 0);
}
// Apply weighted moving average to smooth individual spikes
const counts = smoothData(rawCounts);
const maxCount = Math.max(...counts, 1);
// Remove existing heatmap if present
if (heatmapContainer) {
if (heatmapResizeObserver) heatmapResizeObserver.disconnect();
heatmapContainer.remove();
heatmapCanvas = null;
}
const progressBar = document.querySelector('.hstream-player__progress');
if (!progressBar) return;
heatmapContainer = document.createElement('div');
heatmapContainer.className = 'hstream-player__progress-heatmap';
heatmapContainer.setAttribute('aria-hidden', 'true');
heatmapCanvas = document.createElement('canvas');
heatmapCanvas.className = 'hstream-player__progress-heatmap-canvas';
heatmapContainer.appendChild(heatmapCanvas);
// Insert as first child of the progress bar so it sits behind the scrubber
progressBar.insertBefore(heatmapContainer, progressBar.firstChild);
// Defer drawing to get container dimensions
requestAnimationFrame(() => {
requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount));
});
// Redraw on resize
heatmapResizeObserver = new ResizeObserver(() => {
drawCurve(heatmapCanvas, counts, maxCount);
});
heatmapResizeObserver.observe(heatmapContainer);
}
/**
* Apply two passes of a 5-point weighted (Gaussian) moving average.
* Near edges falls back to a 3-point average.
* Preserves the first and last points.
*/
function smoothData(data) {
if (data.length <= 2) return [...data];
let result = data;
for (let pass = 0; pass < 2; pass++) {
const smoothed = [result[0]];
for (let i = 1; i < result.length - 1; i++) {
if (result.length > 4 && i >= 2 && i <= result.length - 3) {
smoothed.push(
(result[i - 2] * 1 + result[i - 1] * 2 + result[i] * 4 +
result[i + 1] * 2 + result[i + 2] * 1) / 10
);
} else {
smoothed.push((result[i - 1] + result[i] + result[i + 1]) / 3);
}
}
smoothed.push(result[result.length - 1]);
result = smoothed;
}
return result;
}
/**
* Render vertical bar heatmap directly on the progress bar track.
* Each bar represents a time segment; taller bars = more engagement.
*/
function drawCurve(canvas, counts, maxCount) {
const parent = canvas.parentElement;
if (!parent) return;
const rect = parent.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const dpr = window.devicePixelRatio || 1;
const w = rect.width;
const h = rect.height;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
if (counts.length === 0 || maxCount === 0) return;
const paddingX = 1;
const paddingY = 2;
const drawW = w - paddingX * 2;
const drawH = h - paddingY * 2;
const n = counts.length;
const pts = [];
for (let i = 0; i < n; i++) {
const x = paddingX + (i / (n - 1 || 1)) * drawW;
const ratio = Math.min(counts[i] / maxCount, 1);
const y = paddingY + (1 - ratio) * drawH;
pts.push({ x, y });
}
if (pts.length < 2) return;
// Build the smooth path using quadratic bezier curves through midpoints
const path = [{ x: pts[0].x, y: pts[0].y }];
for (let i = 0; i < pts.length - 1; i++) {
const midX = (pts[i].x + pts[i + 1].x) / 2;
const midY = (pts[i].y + pts[i + 1].y) / 2;
path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } });
}
path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y });
// --- Draw a subtle glow behind the line ---
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
const prev = path[i - 1];
const curr = path[i];
if (curr.cp) {
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
} else {
ctx.lineTo(curr.x, curr.y);
}
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 3.0;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
// --- Draw the main waveform line ---
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
const prev = path[i - 1];
const curr = path[i];
if (curr.cp) {
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
} else {
ctx.lineTo(curr.x, curr.y);
}
}
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
}
/**
* Remove the heatmap from the DOM.
*/
export function removeHeatmap() {
if (heatmapResizeObserver) {
heatmapResizeObserver.disconnect();
heatmapResizeObserver = null;
}
if (heatmapContainer) {
heatmapContainer.remove();
heatmapContainer = null;
heatmapCanvas = null;
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Mobile-specific player features:
* - Double-tap left/right to skip ±10s
* - Object-fit toggle button for widescreen fill
*/
export function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
export function initMobileWidescreen(playerWrapper, video) {
if (!isMobile()) {
return;
}
const controls = playerWrapper.querySelector('.hstream-player__controls');
if (!controls) {
return;
}
const btn = document.createElement('button');
btn.className = 'hstream-player__button hstream-player__mobile-fill-btn';
btn.type = 'button';
btn.setAttribute('aria-label', 'Toggle screen fill');
btn.innerHTML = '<i class="fa-solid fa-arrows-left-right-to-line"></i>';
btn.title = 'Fill Screen';
const fullscreenBtn = controls.querySelector('[data-action="fullscreen"]');
if (fullscreenBtn) {
fullscreenBtn.insertAdjacentElement('beforebegin', btn);
} else {
controls.appendChild(btn);
}
let fillEnabled = true;
video.style.objectFit = 'cover';
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (fillEnabled) {
video.style.objectFit = 'contain';
fillEnabled = false;
btn.classList.remove('hstream-player__button--active');
} else {
video.style.objectFit = 'cover';
fillEnabled = true;
btn.classList.add('hstream-player__button--active');
}
});
btn.classList.add('hstream-player__button--active');
}
export function initMobileDoubleTap(playerWrapper, video, player) {
if (!isMobile()) {
return;
}
const skipOverlay = playerWrapper.querySelector('.hstream-player__skip-overlay');
if (!skipOverlay) {
return;
}
class MultiClickCounter {
constructor() {
this.timers = [];
this.count = 0;
this.reseted = 0;
this.lastSide = null;
}
clicked() {
this.count += 1;
const xcount = this.count;
this.timers.push(setTimeout(() => this.reset(xcount), 500));
return this.count;
}
resetCount(n) {
this.reseted = this.count;
this.count = n;
this.timers.forEach(t => clearTimeout(t));
this.timers = [];
}
reset(xcount) {
if (this.count > xcount) return;
this.count = 0;
this.lastSide = null;
this.reseted = 0;
skipOverlay.classList.remove('hstream-player__skip-overlay--visible');
this.timers = [];
}
}
const counter = new MultiClickCounter();
const handleTap = (e) => {
e.preventDefault();
const count = counter.clicked();
if (count < 2) return;
const rect = e.target.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const perc = (x / rect.width) * 100;
let shouldReset = true;
const lastSide = counter.lastSide;
if (lastSide === null) {
shouldReset = false;
}
if (perc < 40) {
if (player.currentTime === 0) return;
counter.lastSide = 'L';
if (shouldReset && lastSide !== 'L') {
counter.resetCount(1);
return;
}
const skipSeconds = (count - 1) * 10;
player.currentTime = Math.max(0, player.currentTime - skipSeconds);
skipOverlay.innerHTML = '<i class="fa-solid fa-backward"></i>' + skipSeconds + 's';
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
} else if (perc > 60) {
if (player.currentTime >= player.duration) return;
counter.lastSide = 'R';
if (shouldReset && lastSide !== 'R') {
counter.resetCount(1);
return;
}
const skipSeconds = (count - 1) * 10;
player.currentTime = Math.min(player.duration, player.currentTime + skipSeconds);
skipOverlay.innerHTML = '<i class="fa-solid fa-forward"></i>' + skipSeconds + 's';
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
} else {
player.togglePlay();
counter.lastSide = 'C';
}
};
playerWrapper.addEventListener('click', handleTap);
video.addEventListener('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
@@ -0,0 +1,69 @@
/**
* Builds the server/CDN selector submenu panel for the settings menu.
* @param {string[]} streamServers - Regular CDN server URLs
* @param {string[]} fallbackServers - Fallback server URLs
* @param {number} selectedIndex - Index in the combined server list
* @param {function} onSelect - Callback receiving the combined index
*/
export function buildServerMenu(streamServers, fallbackServers, selectedIndex, onSelect) {
const panel = document.createElement('div');
panel.className = 'hstream-player__menu-panel';
panel.setAttribute('data-panel', 'server');
const backBtn = document.createElement('button');
backBtn.className = 'hstream-player__menu-back';
backBtn.type = 'button';
backBtn.innerHTML = '<i class="fa-solid fa-chevron-left"></i> Server';
backBtn.addEventListener('click', (e) => {
e.stopPropagation();
const menuContainer = panel.closest('.hstream-player__menu-container');
if (menuContainer) {
menuContainer.querySelectorAll('.hstream-player__menu-panel').forEach(p => p.classList.remove('hstream-player__menu-panel--active'));
const mainPanel = menuContainer.querySelector('[data-panel="main"]');
if (mainPanel) mainPanel.classList.add('hstream-player__menu-panel--active');
}
});
panel.appendChild(backBtn);
const addServerItems = (servers, labelPrefix, startIndex) => {
for (let i = 0; i < servers.length; i++) {
const index = startIndex + i;
const item = document.createElement('button');
item.className = 'hstream-player__menu-item';
item.type = 'button';
item.setAttribute('role', 'menuitemradio');
if (index === selectedIndex) {
item.classList.add('hstream-player__menu-item--checked');
item.setAttribute('aria-checked', 'true');
} else {
item.setAttribute('aria-checked', 'false');
}
const num = i + 1;
item.innerHTML = `<span>${labelPrefix} ${num} <span class="hstream-player__menu-value"><span class="hstream-player__menu-badge">${labelPrefix}${num}</span></span></span><span class="hstream-player__menu-item-radio"></span>`;
item.addEventListener('click', (e) => {
e.stopPropagation();
onSelect(index);
});
panel.appendChild(item);
}
};
const divider = document.createElement('div');
divider.className = 'hstream-player__menu-divider';
panel.appendChild(divider);
addServerItems(streamServers, 'Server', 0);
if (fallbackServers && fallbackServers.length > 0) {
const fbDivider = document.createElement('div');
fbDivider.className = 'hstream-player__menu-divider';
panel.appendChild(fbDivider);
addServerItems(fallbackServers, 'Fallback', streamServers.length);
}
return panel;
}
+218
View File
@@ -0,0 +1,218 @@
/**
* VTT-based sprite thumbnail preview.
* Parses WEBVTT cues with Media Fragment URIs (#xywh=x,y,w,h) and renders
* a floating preview image above the progress bar on hover.
*/
export class ThumbnailPreview {
constructor(progressWrapper, video) {
this.progressWrapper = progressWrapper;
this.video = video;
this.cues = [];
this.spriteImg = null;
this.thumbnailWidth = 160;
this.thumbnailHeight = 90;
this.visible = false;
this.el = document.createElement('div');
this.el.className = 'hstream-player__thumbnail-preview';
this.el.setAttribute('aria-hidden', 'true');
this.imgEl = document.createElement('div');
this.imgEl.className = 'hstream-player__thumbnail-preview-img';
this.el.appendChild(this.imgEl);
this.timeEl = document.createElement('div');
this.timeEl.className = 'hstream-player__thumbnail-preview-time';
this.el.appendChild(this.timeEl);
this.el.style.display = 'none';
this.progressWrapper.appendChild(this.el);
this._onMove = this._onMove.bind(this);
this._onLeave = this._onLeave.bind(this);
}
/**
* Fetch and parse the thumbnail VTT file.
* @param {string} vttUrl
*/
async load(vttUrl) {
try {
const response = await fetch(vttUrl);
if (!response.ok) {
throw new Error('Failed to fetch VTT: ' + response.status);
}
const text = await response.text();
const baseDir = vttUrl.substring(0, vttUrl.lastIndexOf('/') + 1);
this.cues = this._parseVTT(text, baseDir);
if (this.cues.length > 0) {
this.spriteImg = new Image();
this.spriteImg.crossOrigin = 'anonymous';
this.spriteImg.src = this.cues[0].spriteUrl;
await new Promise((resolve, reject) => {
this.spriteImg.onload = resolve;
this.spriteImg.onerror = reject;
});
}
this._attach();
} catch (err) {
console.warn('[ThumbnailPreview] Could not load thumbnails:', err);
}
}
/**
* Parse WEBVTT text extracting cues with sprite coordinates.
*/
_parseVTT(text, baseDir) {
const cues = [];
const lines = text.split(/\r?\n/);
const cueRegex = /^(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/;
const xywhRegex = /#xywh=(\d+),(\d+),(\d+),(\d+)/;
const resolveUrl = (maybeRelative) => {
if (!baseDir || maybeRelative.startsWith('http://') || maybeRelative.startsWith('https://') || maybeRelative.startsWith('data:') || maybeRelative.startsWith('/')) {
return maybeRelative;
}
try {
return new URL(maybeRelative, baseDir).href;
} catch (e) {
return baseDir + maybeRelative;
}
};
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
const match = line.match(cueRegex);
if (match) {
const startTime = this._timeToSeconds(match[1]);
const endTime = this._timeToSeconds(match[2]);
i++;
while (i < lines.length) {
const payload = lines[i].trim();
if (payload === '' || payload.match(cueRegex)) {
break;
}
const xywh = payload.match(xywhRegex);
if (xywh) {
const rawUrl = payload.substring(0, xywh.index);
cues.push({
startTime,
endTime,
spriteUrl: resolveUrl(rawUrl),
x: parseInt(xywh[1], 10),
y: parseInt(xywh[2], 10),
w: parseInt(xywh[3], 10),
h: parseInt(xywh[4], 10),
});
break;
}
const noteMatch = payload.match(/^NOTE/);
if (!noteMatch) {
const urlMatch = payload.match(/^(\S+)/);
if (urlMatch) {
cues.push({ startTime, endTime, spriteUrl: resolveUrl(urlMatch[1]), x: 0, y: 0, w: 0, h: 0 });
break;
}
}
i++;
}
}
i++;
}
return cues;
}
_timeToSeconds(timestamp) {
const [h, m, s] = timestamp.split(':');
return parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s);
}
_attach() {
this.progressWrapper.addEventListener('mousemove', this._onMove);
this.progressWrapper.addEventListener('mouseleave', this._onLeave);
this.progressWrapper.addEventListener('touchmove', this._onMove, { passive: true });
this.progressWrapper.addEventListener('touchend', this._onLeave);
}
_onMove(e) {
const rect = this.progressWrapper.getBoundingClientRect();
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
const ratio = Math.max(0, Math.min(1, x / rect.width));
const time = ratio * this.video.duration;
const cue = this._findCue(time);
if (!cue) {
this._hide();
return;
}
this._show(cue, time, rect, x);
}
_findCue(time) {
for (let i = 0; i < this.cues.length; i++) {
if (time >= this.cues[i].startTime && time <= this.cues[i].endTime) {
return this.cues[i];
}
}
return null;
}
_show(cue, time, progressRect, mouseX) {
this.imgEl.style.backgroundImage = `url(${cue.spriteUrl})`;
this.imgEl.style.width = cue.w + 'px';
this.imgEl.style.height = cue.h + 'px';
this.imgEl.style.backgroundPosition = `-${cue.x}px -${cue.y}px`;
const mins = Math.floor(time / 60);
const secs = Math.floor(time % 60);
this.timeEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
const containerWidth = this.progressWrapper.offsetWidth;
const halfW = cue.w / 2;
let left = mouseX;
if (left < halfW + 4) left = halfW + 4;
if (left > containerWidth - halfW - 4) left = containerWidth - halfW - 4;
this.el.style.left = left + 'px';
this.el.style.display = '';
const timeTooltip = this.progressWrapper.querySelector('.hstream-player__time-tooltip');
if (timeTooltip) {
timeTooltip.classList.remove('hstream-player__time-tooltip--visible');
}
if (!this.visible) {
this.visible = true;
requestAnimationFrame(() => {
this.el.classList.add('hstream-player__thumbnail-preview--visible');
});
}
}
_hide() {
this.visible = false;
this.el.classList.remove('hstream-player__thumbnail-preview--visible');
setTimeout(() => {
if (!this.visible) {
this.el.style.display = 'none';
}
}, 150);
}
_onLeave() {
this._hide();
}
destroy() {
this.progressWrapper.removeEventListener('mousemove', this._onMove);
this.progressWrapper.removeEventListener('mouseleave', this._onLeave);
this.progressWrapper.removeEventListener('touchmove', this._onMove);
this.progressWrapper.removeEventListener('touchend', this._onLeave);
if (this.el.parentNode) {
this.el.parentNode.removeChild(this.el);
}
}
}