Universal Video Caption

Overlay custom subtitles (.srt) on any HTML5 video. Features sync adjustment, custom styling, timestamp search, and Drag & Drop.

目前為 2025-12-15 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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;">&times;</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)} &rarr; ${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);

})();