/** * 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); } } }