Overlay custom subtitles (.srt) on any HTML5 video. Features sync adjustment, custom styling, timestamp search, and Drag & Drop.
目前為
// ==UserScript==
// @name Universal Video Caption
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Overlay custom subtitles (.srt) on any HTML5 video. Features sync adjustment, custom styling, timestamp search, and Drag & Drop.
// @author an-swe
// @license MIT
// @match *://*/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// --- 1. CSS Styles ---
const STYLES = `
.uvc-overlay {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
font-family: Arial, sans-serif;
font-weight: bold;
text-shadow: 2px 2px 2px #000;
border-radius: 5px;
pointer-events: none;
transition: opacity 0.2s ease-out;
z-index: 2147483647;
width: 80%;
padding: 5px 10px;
bottom: 5%;
height: fit-content;
width: fit-content;
}
/* Specific styling for the top/previous line */
.uvc-prev-line {
font-size: 0.8em; /* Slightly smaller */
display: block;
margin-bottom: 5px; /* Separate the two lines */
}
.uvc-drag-over {
outline: 5px dashed #007bff; /* Use outline instead of border to avoid layout shift */
outline-offset: -5px;
background-color: rgba(0, 123, 255, 0.1);
}
.uvc-control-btn {
position: absolute;
top: 10px;
left: 10px;
z-index: 2147483647;
background: rgba(0, 0, 0, 0.6);
color: white;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
width: fit-content;
height: fit-content;
}
*:hover > .uvc-control-btn { opacity: 1; }
.uvc-panel {
position: fixed;
background-color: #222;
color: #eee;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0,0,0,0.8);
z-index: 2147483647;
font-family: sans-serif;
font-size: 13px;
}
.uvc-panel-header {
padding: 10px;
background: #333;
border-bottom: 1px solid #444;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 8px 8px 0 0;
font-weight: bold;
}
.uvc-panel-body { padding: 15px; display: flex; flex-direction: column; gap: 10px; }
.uvc-row { display: flex; flex-direction: column; gap: 4px; }
.uvc-row label { font-size: 12px; color: #aaa; display: flex; justify-content: space-between; }
.uvc-input { background: #444; border: 1px solid #555; color: white; padding: 4px; border-radius: 4px; width: 100%; box-sizing: border-box; }
.uvc-btn { background: #007bff; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer; margin-top: 5px; }
.uvc-btn:hover { background: #0056b3; }
.uvc-btn.secondary { background: #555; }
/* Larger, more clickable checkboxes */
.uvc-checkbox {
cursor: pointer;
margin-left: 8px;
}
/* Timestamp Sidebar Specifics */
.uvc-sidebar-list { overflow-y: auto; max-height: calc(100vh - 150px); flex-grow: 1; }
.uvc-ts-row { padding: 8px; border-bottom: 1px dotted #444; cursor: pointer; font-size: 12px; line-height: 1.4; }
.uvc-ts-row:hover { background: #333; }
.uvc-ts-row.active { background: #007bff; color: white; }
.uvc-ts-meta { color: #bbb; margin-bottom: 3px; font-family: monospace; font-size: 11px; }
.uvc-ts-row.active .uvc-ts-meta { color: #eee; }
`;
const styleEl = document.createElement('style');
styleEl.textContent = STYLES;
document.head.appendChild(styleEl);
// --- 2. Helpers ---
const formatTime = (seconds) => {
if (isNaN(seconds)) return '00:00:00';
const date = new Date(0);
date.setMilliseconds(seconds * 1000);
return date.toISOString().substr(11, 12);
};
const parseSRT = (text) => {
const subs = [];
const pattern = /(\d+)\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\n([\s\S]*?)(?=\n\n|\n*$)/g;
const timeToSec = (t) => {
const [h, m, s] = t.split(':');
return (+h) * 3600 + (+m) * 60 + parseFloat(s.replace(',', '.'));
};
let match;
while ((match = pattern.exec(text.replace(/\r\n|\r/g, '\n'))) !== null) {
subs.push({
start: timeToSec(match[2]),
end: timeToSec(match[3]),
text: match[4].replace(/\n/g, '<br>')
});
}
return subs;
};
const sanitizeSubtitleText = (text) => {
// Remove {.*} patterns (speaker names, music cues, etc.)
text = text.replace(/\{[^}]*\}/g, '').trim();
text = text.replace(/<\/?[^>]+(>|$)/g, '').trim();
return text;
};
const STATUS_LOADED_BG = 'rgba(40, 167, 69, 0.8)';
const getUrlKey = () => {
const url = window.location.href.split('?')[0].split('#')[0];
return 'uvc_cache_' + btoa(url);
};
// --- 3. Persistence & Config ---
const DEFAULT_CONFIG = {
fontSizeRatio: 3.5,
color: '#ffffff',
bgColor: '#000000',
bgOpacity: 70,
offsetMs: 0,
dualLineEnabled: false,
dualLineOpacity: 60,
alignTop: false
};
const getConfig = () => {
try {
const saved = JSON.parse(localStorage.getItem('uvc_config'));
return { ...DEFAULT_CONFIG, ...(saved || {}) };
} catch { return DEFAULT_CONFIG; }
};
const saveConfig = (cfg) => localStorage.setItem('uvc_config', JSON.stringify(cfg));
// Construct rgba from bgColor and opacity
const constructBgColor = (color, opacity) => {
// Robustly convert HEX to RGB components if a hex code is stored
const hexMatch = color.match(/^#?([a-f\d]{6})$/i);
if (hexMatch) {
const bigint = parseInt(hexMatch[1], 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba(${r}, ${g}, ${b}, ${opacity / 100})`;
}
// Fallback for direct RGB string inputs (shouldn't happen with current UI but safer)
const match = color.match(/(\d+),\s*(\d+),\s*(\d+)/);
if (match) return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity / 100})`;
return `rgba(0, 0, 0, ${opacity / 100})`;
};
// --- 4. The Core Class ---
class CaptionInstance {
constructor(video) {
this.video = video;
this.subs = [];
this.config = getConfig();
this.overlay = null;
this.controlBtn = null;
CaptionInstance.dragDropSetup = CaptionInstance.dragDropSetup || false;
// State
this.cachedIndex = 0;
this.fileInput = null;
this.isRebuildingTimeline = false; // Flag for rebuild-time scrolling
this.init();
}
init() {
// 1. Create Overlay
this.overlay = document.createElement('div');
this.overlay.className = 'uvc-overlay';
this.updateStyles();
// 2. Create Control Button
this.controlBtn = document.createElement('div');
this.controlBtn.className = 'uvc-control-btn';
this.controlBtn.innerHTML = `
<span id="uvc-upload-status">📁 CC Upload</span>
<span id="uvc-close-btn" style="margin-left: 10px; font-weight: bold; cursor: pointer; color: #f44336; padding: 0 4px; border: 1px solid #f44336; border-radius: 4px; line-height: 1;">×</span>
`;
// Add listener for the close button right after appending
this.controlBtn.querySelector('#uvc-close-btn').onclick = (e) => {
e.stopPropagation(); // Prevent triggering openMenu()
this.controlBtn.remove();
};
this.controlBtn.addEventListener('click', () => this.openMenu());
// 3. Mount to DOM
const parent = this.video.parentElement;
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
}
console.log(`UVC Debug: Overlay element created and mounted under video parent. Element:`, this.overlay);
parent.appendChild(this.overlay);
parent.appendChild(this.controlBtn);
// Get reference to the status span for updates
this.statusSpan = this.controlBtn.querySelector('#uvc-upload-status');
// 4. Input handling
this.fileInput = document.createElement('input');
this.fileInput.type = 'file';
this.fileInput.accept = '.srt';
this.fileInput.style.display = 'none';
this.fileInput.onchange = (e) => this.handleSrtFile(e.target.files[0], true);
document.body.appendChild(this.fileInput);
// 5. Listeners
this.video.addEventListener('timeupdate', () => this.onTimeUpdate());
this.video.addEventListener('seeking', () => this.onTimeUpdate());
new ResizeObserver(() => this.updateStyles()).observe(this.video);
// 6. Check Cache
if (!CaptionInstance.dragDropSetup) {
this.setupGlobalDragAndDrop(this.handleSrtFile.bind(this));
CaptionInstance.dragDropSetup = true;
}
if (!this.loadFromCache()) {
this.updateControlStatus(false);
}
}
// --- CACHE IMPLEMENTATION: Core Subtitle Loaders ---
// Processes SRT content and updates the instance state
processSubtitles(srtText) {
// Parse, then sanitize once during load so rendering stays lightweight
const parsed = parseSRT(srtText);
this.subs = parsed.map(s => ({
start: s.start,
end: s.end,
text: sanitizeSubtitleText(s.text)
}));
this.cachedIndex = 0;
}
// Updates the control button's display state
updateControlStatus(isLoaded) {
if (isLoaded) {
this.statusSpan.innerHTML = '✅ CC Loaded';
this.controlBtn.style.background = STATUS_LOADED_BG;
setTimeout(() => {
this.statusSpan.innerHTML = '⚙️ CC Settings';
this.controlBtn.style.background = '';
}, 2000);
} else {
this.statusSpan.innerHTML = '📁 CC Upload';
this.controlBtn.style.background = '';
}
}
updateStyles() {
if (!this.overlay) return;
const h = this.video.offsetHeight;
this.overlay.style.fontSize = (h * (this.config.fontSizeRatio / 100)) + 'px';
this.overlay.style.color = this.config.color;
this.overlay.style.backgroundColor = constructBgColor(this.config.bgColor, this.config.bgOpacity);
// Positioning: toggle between top and bottom alignment
if (this.config.alignTop) {
this.overlay.style.top = '5%';
this.overlay.style.bottom = '';
} else {
this.overlay.style.bottom = '5%';
this.overlay.style.top = '';
}
}
loadFromCache() {
const srtText = localStorage.getItem(getUrlKey());
if (srtText) {
console.log('UVC Debug: Cache hit detected.');
try {
this.processSubtitles(srtText);
this.updateControlStatus(true);
this.statusSpan.innerHTML = '🧠 CC Cached';
return true;
} catch (err) {
console.error('UVC: Failed to load cached SRT', err);
localStorage.removeItem(getUrlKey());
}
}
return false;
}
handleSrtFile(file, resetInput = false) {
if (!file || !file.name.toLowerCase().endsWith('.srt')) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const srtText = evt.target.result;
this.processSubtitles(srtText);
localStorage.setItem(getUrlKey(), srtText); // Cache the file
this.updateControlStatus(true);
// FIX 1: Ensure stale information is updated immediately
const sidebar = document.getElementById('uvc-sidebar');
if (sidebar) this.createSidebar(); // Redraw sidebar with new data
this.onTimeUpdate(); // Force render of current frame's subtitle
} catch (err) {
alert('Failed to parse SRT');
}
};
reader.readAsText(file);
if (resetInput && this.fileInput) {
this.fileInput.value = ''; // Reset file input to allow loading the same file again
}
}
// Global setup for drag and drop (static/run once)
setupGlobalDragAndDrop(handler) {
const body = document.body;
let dragCounter = 0;
// Prevent default drag behaviors
const preventDefaults = (e) => {
e.preventDefault();
e.stopPropagation();
};
// Handle file drop
const handleDrop = (e) => {
body.classList.remove('uvc-drag-over');
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) handler(files[0], true);
};
// Attach listeners to the window/body
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, preventDefaults, false);
});
document.addEventListener('dragenter', (e) => {
dragCounter++;
if (e.dataTransfer && Array.from(e.dataTransfer.items).some(i => i.kind === 'file')) {
body.classList.add('uvc-drag-over');
}
}, false);
document.addEventListener('dragleave', () => {
dragCounter--;
if (dragCounter <= 0) body.classList.remove('uvc-drag-over');
}, false);
document.addEventListener('drop', handleDrop, false);
}
onTimeUpdate(isInstant = true) {
if (!this.subs.length) return;
const time = this.video.currentTime + (this.config.offsetMs / 1000);
let activeSub = null;
if (this.subs[this.cachedIndex] && time >= this.subs[this.cachedIndex].start && time <= this.subs[this.cachedIndex].end) {
activeSub = this.subs[this.cachedIndex];
}
else if (this.subs[this.cachedIndex + 1] && time >= this.subs[this.cachedIndex + 1].start && time <= this.subs[this.cachedIndex + 1].end) {
this.cachedIndex++;
activeSub = this.subs[this.cachedIndex];
}
else {
// --- General Search / Seek Logic ---
let foundIndex = -1;
// 1. Try finding the *currently active* subtitle (time between start/end) using linear forward search
foundIndex = this.subs.findIndex(s => time >= s.start && time <= s.end);
if (foundIndex !== -1) {
this.cachedIndex = foundIndex;
activeSub = this.subs[foundIndex];
}
// 2. If not currently active (e.g., seeking backward into a gap or the middle of a previous line),
// search backwards to find the last subtitle that *started* before the current time.
if (!activeSub) {
for (let i = this.subs.length - 1; i >= 0; i--) {
if (time >= this.subs[i].start) {
this.cachedIndex = i;
activeSub = this.subs[i];
break;
}
}
}
}
// --- Dual-Line Rendering Logic ---
let overlayContent = '';
if (activeSub) {
const currentIndex = this.cachedIndex;
if (this.config.dualLineEnabled && currentIndex > 0) {
const prevSub = this.subs[currentIndex - 1];
const sanitizedText = sanitizeSubtitleText(prevSub.text);
const opacityStyle = `opacity: ${this.config.dualLineOpacity / 100};`;
overlayContent += `<span class="uvc-prev-line" style="${opacityStyle}">${sanitizedText}</span>`;
}
// The current/latest subtitle is the main line
const sanitizedMainText = sanitizeSubtitleText(activeSub.text);
overlayContent += `<span>${sanitizedMainText}</span>`;
this.overlay.innerHTML = overlayContent;
if (this.overlay.style.display !== 'block') console.log(`UVC Debug: Showing new subtitle at ${time.toFixed(3)}s. Text: "${activeSub.text.substring(0, 30)}..."`);
this.overlay.style.display = 'block';
// Highlight sidebar with smooth scroll on seek, no scroll otherwise
const behavior = this.video.seeking ? 'smooth' : null;
this.highlightSidebar(this.cachedIndex, behavior);
} else if (this.overlay.style.display === 'block') {
console.log(`UVC Debug: Hiding subtitle at ${time.toFixed(3)}s.`);
this.overlay.style.display = 'none';
}
}
openMenu() {
if (!this.subs.length) {
this.fileInput.click();
return;
}
this.createSettingsPanel();
}
createSettingsPanel() {
if (document.getElementById('uvc-settings-panel')) return;
const panel = document.createElement('div');
panel.id = 'uvc-settings-panel';
panel.className = 'uvc-panel';
panel.style.top = '50px';
panel.style.left = '50px';
panel.style.width = '320px';
const header = document.createElement('div');
panel.innerHTML = `
<div class="uvc-panel-header">
<span>Caption Settings</span>
<span style="cursor:pointer;" id="uvc-close">✕</span>
</div>
<div class="uvc-panel-body">
<button class="uvc-btn secondary" id="uvc-load-new">Load New SRT</button>
<div class="uvc-row">
<label>Size (%) <span id="val-size">${this.config.fontSizeRatio}</span></label>
<input type="range" class="uvc-input" min="1" max="10" step="0.1" value="${this.config.fontSizeRatio}" id="inp-size">
</div>
<div class="uvc-row">
<label>Background Color</label>
<input type="color" class="uvc-input" value="${this.config.bgColor}" id="inp-bgColor">
</div>
<div class="uvc-row">
<label>Font Color</label>
<input type="color" class="uvc-input" value="${this.config.color}" id="inp-fontColor">
</div>
<div class="uvc-row">
<label>Background Opacity (%) <span id="val-opacity">${this.config.bgOpacity}</span></label>
<input type="range" class="uvc-input" min="0" max="100" step="5" value="${this.config.bgOpacity}" id="inp-opacity">
</div>
<hr style="border-top: 1px solid #444; margin: 10px 0;">
<div class="uvc-row" style="display: flex; flex-direction: row; justify-content: space-between; align-items: center; gap: 8px;">
<label for="inp-align-top" style="margin: 0; cursor: pointer;">Align Top</label>
<input type="checkbox" id="inp-align-top" ${this.config.alignTop ? 'checked' : ''} class="uvc-checkbox">
</div>
<div class="uvc-row" style="display: flex; flex-direction: row; justify-content: space-between; align-items: center; gap: 8px;">
<label for="inp-dual-enabled" style="margin: 0; cursor: pointer;">Dual Line Subtitles</label>
<input type="checkbox" id="inp-dual-enabled" ${this.config.dualLineEnabled ? 'checked' : ''} class="uvc-checkbox">
</div>
<div class="uvc-row">
<label>Top Line Opacity (%) <span id="val-dual-opacity">${this.config.dualLineOpacity}</span></label>
<input type="range" class="uvc-input" min="0" max="100" step="5" value="${this.config.dualLineOpacity}" id="inp-dual-opacity">
</div>
<hr style="border-top: 1px solid #444; margin: 10px 0;">
<button class="uvc-btn" id="uvc-view-subs">Timeline</button>
</div>
`;
document.body.appendChild(panel);
document.getElementById('uvc-close').onclick = () => panel.remove();
document.getElementById('uvc-load-new').onclick = () => this.fileInput.click();
document.getElementById('uvc-view-subs').onclick = () => { this.createSidebar(); };
const updateVal = (id, val) => document.getElementById(id).innerText = val;
// --- Draggable Logic ---
const dragHandle = panel.querySelector('.uvc-panel-header');
let isDragging = false;
let offsetX, offsetY;
const startDrag = (e) => {
if (e.button !== 0) return;
isDragging = true;
// Calculate offset from mouse to panel corner
offsetX = e.clientX - panel.getBoundingClientRect().left;
offsetY = e.clientY - panel.getBoundingClientRect().top;
// Set style properties for drag
panel.style.cursor = 'grabbing';
panel.style.transition = 'none';
document.addEventListener('mousemove', dragMove);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
};
const dragMove = (e) => {
if (!isDragging) return;
// Calculate new position
let newX = e.clientX - offsetX;
let newY = e.clientY - offsetY;
panel.style.left = `${newX}px`;
panel.style.top = `${newY}px`;
panel.style.right = 'auto'; // Disable right setting when dragging is active
};
const stopDrag = () => {
isDragging = false;
panel.style.cursor = 'grab';
panel.style.transition = '';
document.removeEventListener('mousemove', dragMove);
document.removeEventListener('mouseup', stopDrag);
};
dragHandle.style.cursor = 'grab'; // Indicate drag-and-drop capability
dragHandle.addEventListener('mousedown', startDrag);
// Prevent text selection during drag
dragHandle.addEventListener('selectstart', (e) => e.preventDefault());
// --- End Draggable Logic ---
document.getElementById('inp-size').oninput = (e) => {
this.config.fontSizeRatio = parseFloat(e.target.value);
updateVal('val-size', this.config.fontSizeRatio);
this.updateStyles();
saveConfig(this.config);
};
document.getElementById('inp-bgColor').oninput = (e) => {
this.config.bgColor = e.target.value;
this.updateStyles();
saveConfig(this.config);
};
document.getElementById('inp-fontColor').oninput = (e) => {
this.config.color = e.target.value;
this.updateStyles();
saveConfig(this.config);
};
document.getElementById('inp-opacity').oninput = (e) => {
this.config.bgOpacity = parseInt(e.target.value);
updateVal('val-opacity', this.config.bgOpacity);
this.updateStyles();
saveConfig(this.config);
};
// --- Dual Line Handlers ---
const dualEnabledEl = document.getElementById('inp-dual-enabled');
if (dualEnabledEl) {
dualEnabledEl.onchange = (e) => {
this.config.dualLineEnabled = e.target.checked;
saveConfig(this.config);
this.onTimeUpdate();
};
}
// --- Align Top/Bottom Toggle ---
const alignTopEl = document.getElementById('inp-align-top');
if (alignTopEl) {
alignTopEl.onchange = (e) => {
this.config.alignTop = e.target.checked;
saveConfig(this.config);
this.updateStyles();
};
}
const dualOpacityEl = document.getElementById('inp-dual-opacity');
if (dualOpacityEl) {
dualOpacityEl.oninput = (e) => {
this.config.dualLineOpacity = parseInt(e.target.value);
updateVal('val-dual-opacity', this.config.dualLineOpacity);
saveConfig(this.config);
this.onTimeUpdate();
};
}
}
createSidebar() {
const existingSb = document.getElementById('uvc-sidebar');
if (existingSb) existingSb.remove();
const sb = document.createElement('div');
sb.id = 'uvc-sidebar';
sb.className = 'uvc-panel';
sb.style.top = '0';
sb.style.right = '0';
sb.style.height = '100vh';
sb.style.width = '350px';
sb.style.display = 'flex';
sb.style.flexDirection = 'column';
let listHtml = '<div class="uvc-sidebar-list">';
this.subs.forEach((s, i) => {
const offsetSec = this.config.offsetMs / 1000;
const adjStart = s.start + offsetSec;
const adjEnd = s.end + offsetSec;
const offsetSign = this.config.offsetMs >= 0 ? '+' : '';
listHtml += `
<div class="uvc-ts-row" id="ts-row-${i}" data-time="${s.start}">
<div class="uvc-ts-meta">
${formatTime(adjStart)} → ${formatTime(adjEnd)} <span style="color: #4CAF50;">(${offsetSign}${this.config.offsetMs}ms)</span>
</div>
<div>${s.text}</div>
</div>`;
});
listHtml += '</div>';
sb.innerHTML = `
<div class="uvc-panel-header">
<span>Subtitle List</span>
<span style="cursor:pointer;" id="uvc-sb-close">✕</span>
</div>
<div style="padding: 10px; border-bottom: 1px solid #444; background: #2a2a2a; display: flex; flex-direction: column; gap: 8px;">
<div class="uvc-row">
<div style="display: flex; justify-content: space-between; align-items: center;">
<label style="color: #eee; margin: 0;">Sync Offset (ms)</label>
<input type="number" class="uvc-input" value="${this.config.offsetMs}" step="100" id="inp-offset-sb" style="text-align: center; width: 100px; box-sizing: border-box; margin: 0; padding: 5px;">
</div>
</div>
<button id="uvc-jump-active" class="uvc-btn" style="width: 100%; margin:0;">Jump to Active Subtitle</button>
</div>
${listHtml}
`;
document.body.appendChild(sb);
document.getElementById('uvc-sb-close').onclick = () => sb.remove();
document.getElementById('uvc-jump-active').onclick = () => {
this.highlightSidebar(this.cachedIndex, 'smooth');
};
// --- Offset Handlers in Sidebar ---
const offsetInput = document.getElementById('inp-offset-sb');
const updateOffset = (newOffset) => {
newOffset = parseInt(newOffset, 10);
if (isNaN(newOffset)) return;
this.config.offsetMs = newOffset;
saveConfig(this.config);
this.isRebuildingTimeline = true;
this.createSidebar(); // Recreate sidebar to update timestamps and input value
this.isRebuildingTimeline = false;
this.onTimeUpdate();
// Jump current subtitle into view instantly after offset change
this.highlightSidebar(this.cachedIndex, 'instant');
};
offsetInput.onchange = (e) => updateOffset(e.target.value);
sb.querySelectorAll('.uvc-ts-row').forEach(row => {
row.onclick = () => {
const t = parseFloat(row.dataset.time);
// The offset is already applied when displaying, so seek to the adjusted time
this.video.currentTime = Math.max(0, t + (this.config.offsetMs / 1000));
this.video.play();
};
});
}
highlightSidebar(index, scrollBehavior = null) {
const sidebar = document.getElementById('uvc-sidebar');
if (!sidebar) return;
const activeClass = 'active';
const prev = sidebar.querySelector('.' + activeClass);
if (prev) prev.classList.remove(activeClass);
const curr = document.getElementById(`ts-row-${index}`);
if (curr) {
curr.classList.add(activeClass);
// Scroll into view if behavior is specified
if (scrollBehavior) {
curr.scrollIntoView({ behavior: scrollBehavior, block: 'center' });
}
}
}
}
// --- 5. Initialization ---
const seenVideos = new WeakSet();
const initVideo = (video) => {
if (seenVideos.has(video)) return;
if (video.offsetWidth < window.innerWidth / 4) {
return;
}
console.log('UVC: Found video element', video);
seenVideos.add(video);
new CaptionInstance(video);
};
const observer = new MutationObserver(mutations => {
mutations.forEach(m => {
m.addedNodes.forEach(node => {
if (node.nodeName === 'VIDEO') initVideo(node);
if (node.querySelectorAll) node.querySelectorAll('video').forEach(initVideo);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
document.querySelectorAll('video').forEach(initVideo);
})();