Skip to content

VTT Editor Pro v2.1 ‐ How It Works

RDTvlokip edited this page Nov 6, 2025 · 2 revisions

⚙️ VTT Editor Pro v2.1 - How It Works

Technical documentation on the architecture and internal workings of VTT Editor Pro v2.1.


🏗️ General Architecture

Technical Stack

┌─────────────────────────────────────┐
│         HTML/CSS Interface          │
│  (Single Page Application)          │
└─────────────────┬───────────────────┘
                  │
┌─────────────────▼───────────────────┐
│      Vanilla JavaScript Core        │
│  - Cue management                   │
│  - Audio synchronization            │
│  - Event handlers                   │
└─────────────────┬───────────────────┘
                  │
┌─────────────────▼───────────────────┐
│       Wavesurfer.js v7              │
│  - Waveform rendering               │
│  - Audio playback                   │
│  - Regions plugin                   │
└─────────────────────────────────────┘

Single File

  • All-in-one: Single HTML file contains everything
  • No build: No compilation process
  • Inline CSS: Styles in <style>
  • Inline JavaScript: Code in <script>
  • Advantages:
    • ✅ Portable (single file to share)
    • ✅ No npm dependencies
    • ✅ Works offline (except CDN dependencies)
    • ✅ Easy to deploy

📦 Data Structure

Cue Object

Each subtitle is represented by an object:

{
    id: "cue-1234567890-abc123",  // Unique UUID
    start: 5.234,                  // Start timestamp (seconds)
    end: 7.412,                    // End timestamp (seconds)
    text: "Subtitle text",         // Text content
    color: "#1db954"               // RGB/HEX color
}

Global Array

let cues = [];  // Array containing all cues

Operations:

  • Add: cues.push(newCue)
  • Delete: cues = cues.filter(c => c.id !== id)
  • Modify: Direct object mutation
  • Sort: cues.sort((a, b) => a.start - b.start)

🎵 Wavesurfer.js Integration

Initialization

wavesurfer = WaveSurfer.create({
    container: '#waveform',
    waveColor: '#404040',
    progressColor: '#1db954',
    cursorColor: '#1db954',
    height: 180,
    normalize: true,
    backend: 'WebAudio'
});

Regions Plugin

regionsPlugin = wavesurfer.registerPlugin(
    WaveSurfer.Regions.create()
);

Features:

  • Create colored regions on waveform
  • Drag & drop to move
  • Resize handles to adjust timing
  • Events: update-start, update, update-end

🔄 Cues ↔ Regions Synchronization

Principle

Cues (Data)  ←→  Regions (Visual)
    Array           Wavesurfer

Creation: Cue → Region

function createRegion(start, end, color, id) {
    const region = regionsPlugin.addRegion({
        start: start,
        end: end,
        color: color + '40',  // Alpha transparency
        drag: true,
        resize: true,
        id: id
    });
    setupRegionEvents(region);
    return region;
}

Update: Region → Cue

function updateCueFromRegion(region) {
    const cue = cues.find(c => c.id === region.id);
    if (cue) {
        cue.start = region.start;
        cue.end = region.end;
    }
}

Reconstruction

After VTT import or undo/redo:

function rebuildRegions() {
    clearAllRegions();
    cues.forEach(cue => {
        createRegion(cue.start, cue.end, cue.color, cue.id);
    });
}

🎯 Event Flow

Modification Lifecycle

1. User Action
   └─> Event Listener (click, keydown, etc.)
       └─> Update Cue Data
           └─> Update Region Visual
               └─> Save to History
                   └─> Render UI

Example: Text Modification

// 1. User clicks "Save"
elements.saveCueBtn.addEventListener('click', updateCue);

// 2. Function updates data
function updateCue() {
    const cue = cues.find(c => c.id === currentCueId);
    cue.text = elements.cueText.value;  // Update data

    // 3. Save history
    saveToHistory();

    // 4. Re-render UI
    renderCues();
    renderLyrics();
}

💾 History System

Structure

let history = [];      // State stack
let historyIndex = 0;  // Current position

State Saving

function saveToHistory() {
    // Deep clone cues
    const state = JSON.parse(JSON.stringify(cues));

    // Truncate history if not at end
    history = history.slice(0, historyIndex + 1);

    // Add new state
    history.push(state);
    historyIndex++;

    updateUndoRedoButtons();
}

Undo

function undo() {
    if (historyIndex > 0) {
        historyIndex--;
        cues = JSON.parse(JSON.stringify(history[historyIndex]));
        rebuildRegions();
        renderCues();
        renderLyrics();
    }
}

Redo

function redo() {
    if (historyIndex < history.length - 1) {
        historyIndex++;
        cues = JSON.parse(JSON.stringify(history[historyIndex]));
        rebuildRegions();
        renderCues();
        renderLyrics();
    }
}

🔧 v2.1 Features - Implementation

1. Resize Tooltips

Principle: Display tooltip during handle drag.

Wavesurfer Events:

region.on('update-start', () => {
    isResizing = true;
});

region.on('update', (region) => {
    if (isResizing) {
        showResizeTooltip(mouseX, mouseY, region.start);
    }
});

region.on('update-end', () => {
    isResizing = false;
    hideResizeTooltip();
});

Tooltip DOM:

function showResizeTooltip(x, y, time) {
    tooltip.textContent = formatTimeVTT(time);
    tooltip.style.display = 'block';
    tooltip.style.left = `${x}px`;
    tooltip.style.top = `${y - 50}px`;
}

2. Snap-to-Grid

Principle: Round timestamps to nearest interval.

Rounding function:

function snapToGrid(value) {
    if (!snapEnabled) return value;
    return Math.round(value / snapInterval) * snapInterval;
}

Application on resize:

region.on('update-end', () => {
    if (snapEnabled) {
        const snappedStart = snapToGrid(region.start);
        const snappedEnd = snapToGrid(region.end);

        region.setOptions({
            start: snappedStart,
            end: snappedEnd
        });
    }
});

Example:

Interval: 250ms (0.25s)
Value: 5.387s
Rounded: Math.round(5.387 / 0.25) * 0.25 = 5.375s

3. Batch Text Editing

Principle: Apply transformation to multiple cues.

Find & Replace

function batchFindReplace() {
    const findStr = elements.findText.value;
    const replaceStr = elements.replaceText.value;
    const caseSensitive = elements.caseSensitive.checked;

    const flags = caseSensitive ? 'g' : 'gi';
    const regex = new RegExp(findStr, flags);

    cues.forEach(cue => {
        if (regex.test(cue.text)) {
            cue.text = cue.text.replace(regex, replaceStr);
        }
    });

    renderCues();
    saveToHistory();
}

Transform

function batchTransform(type) {
    cues.forEach(cue => {
        switch (type) {
            case 'uppercase':
                cue.text = cue.text.toUpperCase();
                break;
            case 'lowercase':
                cue.text = cue.text.toLowerCase();
                break;
            case 'capitalize':
                cue.text = cue.text.replace(/\b\w/g, c => c.toUpperCase());
                break;
            case 'sentence':
                cue.text = cue.text.charAt(0).toUpperCase() +
                          cue.text.slice(1).toLowerCase();
                break;
        }
    });
}

Prefix/Suffix with Range

function parseCueRange(rangeStr) {
    if (rangeStr === 'all') {
        return Array.from({length: cues.length}, (_, i) => i);
    }

    const indices = [];
    const parts = rangeStr.split(',');

    for (let part of parts) {
        if (part.includes('-')) {
            const [start, end] = part.split('-').map(s => parseInt(s) - 1);
            for (let i = start; i <= end; i++) {
                indices.push(i);
            }
        } else {
            indices.push(parseInt(part) - 1);
        }
    }

    return indices;
}

📤 VTT Import / Export

VTT Parsing

function parseVTT(content) {
    const lines = content.trim().split('\n');
    const cues = [];
    let i = 0;
    let currentColor = '#1db954';

    while (i < lines.length) {
        const line = lines[i].trim();

        // Detect NOTE color
        if (line.startsWith('NOTE color:')) {
            currentColor = extractColor(line);
        }

        // Detect timestamp
        if (line.includes('-->')) {
            const [startStr, endStr] = line.split('-->');
            const start = parseVTTTime(startStr.trim());
            const end = parseVTTTime(endStr.trim());

            i++;
            let text = '';
            while (i < lines.length && lines[i].trim() !== '') {
                text += lines[i].trim() + '\n';
                i++;
            }

            cues.push({
                id: generateId(),
                start,
                end,
                text: text.trim(),
                color: currentColor
            });
        }

        i++;
    }

    return cues;
}

VTT Generation

function exportVTT() {
    let vttContent = 'WEBVTT\n\n';
    let lastColor = null;

    cues.forEach(cue => {
        if (cue.color !== lastColor) {
            vttContent += `NOTE color:${cue.color}\n\n`;
            lastColor = cue.color;
        }

        vttContent += `${formatTimeVTT(cue.start)} --> ${formatTimeVTT(cue.end)}\n`;
        vttContent += `${cue.text}\n\n`;
    });

    // Download
    const blob = new Blob([vttContent], {type: 'text/vtt'});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'subtitles.vtt';
    a.click();
}

🎨 UI Rendering

Cues List Rendering

function renderCues() {
    const container = elements.cuesList;
    container.innerHTML = '';

    cues.forEach((cue, index) => {
        const cueEl = document.createElement('div');
        cueEl.className = 'cue-item';
        cueEl.dataset.cueId = cue.id;

        cueEl.innerHTML = `
            <div class="cue-number">#${index + 1}</div>
            <div class="cue-time">
                ${formatTimeVTT(cue.start)}${formatTimeVTT(cue.end)}
            </div>
            <div class="cue-text">${cue.text}</div>
            <div class="cue-actions">
                <button onclick="editCue('${cue.id}')">✏️</button>
                <button onclick="deleteCue('${cue.id}')">🗑️</button>
            </div>
        `;

        container.appendChild(cueEl);
    });
}

Active Highlighting

wavesurfer.on('timeupdate', (currentTime) => {
    // Find active cue
    const activeCue = cues.find(c =>
        currentTime >= c.start && currentTime < c.end
    );

    // Remove old active
    document.querySelectorAll('.lyric-line.active')
        .forEach(el => el.classList.remove('active'));

    // Add new active
    if (activeCue) {
        const lyricEl = document.querySelector(
            `.lyric-line[data-cue-id="${activeCue.id}"]`
        );
        if (lyricEl) {
            lyricEl.classList.add('active');
            lyricEl.scrollIntoView({block: 'center'});
        }
    }
});

🚀 Optimizations

Performance

  • Debouncing: Limited render events
  • Event delegation: Listeners on parent containers
  • DocumentFragment: Batch DOM insertions
  • RequestAnimationFrame: Smooth animations

Memory

  • Cleanup: Remove listeners on destroy
  • Weak references: For temporary objects
  • Garbage collection: No circular references

🐛 Error Handling

Overlap Detection

function detectOverlaps() {
    const overlaps = [];

    for (let i = 0; i < cues.length; i++) {
        for (let j = i + 1; j < cues.length; j++) {
            if (cues[i].end > cues[j].start &&
                cues[i].start < cues[j].end) {
                overlaps.push(cues[i].id, cues[j].id);
            }
        }
    }

    return [...new Set(overlaps)];
}

Toast Notifications

function showToast(message, type = 'info') {
    const toast = document.createElement('div');
    toast.className = `toast toast-${type}`;
    toast.textContent = message;

    document.body.appendChild(toast);

    setTimeout(() => {
        toast.classList.add('show');
    }, 10);

    setTimeout(() => {
        toast.classList.remove('show');
        setTimeout(() => toast.remove(), 300);
    }, 3000);
}

← Back to home | Usage guide →

Clone this wiki locally