219 lines
7.7 KiB
JavaScript
219 lines
7.7 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|