Skip to content

Mini-Project: Single-file, offline, simple HTML video player with subs + scrolling transcript


Published Jan 16, 2025

The problem

I have a specific method of watching lectures. I'll first download the raw video files (always useful in case I lose internet connection). I'll then process them through Premiere Pro (or Kdenlive, although it's not as good) to remove silences and filler words, and re-export the new subtitles and new video file.

Great, I now have a video and subtitle file that can be played using a program like VLC. Only problem is, I've become used to YouTube-like video player interfaces, which have a very useful feature that VLC doesn't:

YouTube transcript window

The transcript! I can read much faster than I can listen, so having a live transcript window on the side with clickable subtitles allows for much more efficient video-watching.

I didn't want to use anything I couldn't trust or understand, and I certainly didn't want to re-upload a few GB of videos to YouTube to use their interface.

Claude to the rescue

I was pretty sure that Claude 3.5 Sonnet, Anthropic's free LLM with much better output (subjectively) compared to ChatGPT 4o mini, would be able to help. After a short session, it came up with the below HTML file:

Source code
<!DOCTYPE html>
<html lang="en">
<!-- Previous head content and styles remain exactly the same -->
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Video with Subtitles and Transcript</title>
    <style>
        /* ... (keep all existing styles) ... */
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
            display: flex;
            flex-direction: row;
            height: 100vh;
        }

        .video-container {
            position: relative;
            width: 70%;
            height: 100%;
        }

        video {
            width: 100%;
            height: auto;
        }

        .transcript-container {
            width: 30%;
            height: 100%;
            overflow-y: auto;
            background: #f4f4f4;
            padding: 10px;
        }

        .transcript {
            font-size: 14px;
            line-height: 1.5;
        }

        .subtitle-overlay {
            position: absolute;
            bottom: 10%;
            left: 50%;
            transform: translateX(-50%);
            color: white;
            background: rgba(0, 0, 0, 0.5);
            padding: 10px;
            font-size: 18px;
            display: none;
            text-align: center;
            width: 80%;
        }

        input[type="file"] {
            margin: 10px;
        }

        .transcript-entry {
            padding: 5px;
            margin: 5px 0;
            cursor: pointer;
        }

        .transcript-entry:hover {
            background-color: #e0e0e0;
        }

        .current-subtitle {
            background-color: #ddeeff;
        }

        .speed-indicator {
            position: absolute;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 5px 10px;
            border-radius: 4px;
            font-size: 14px;
            z-index: 1000;
        }

        .controls-info {
            position: absolute;
            top: 40px;
            right: 10px;
            background: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 8px 12px;
            border-radius: 4px;
            font-size: 12px;
            z-index: 1000;
            line-height: 1.5;
        }
    </style>
</head>
<body>
    <div class="video-container">
        <input type="file" id="videoFileInput" accept="video/mp4,video/webm,video/ogg">
        <input type="file" id="subtitleFileInput" accept=".srt">
        <div class="speed-indicator" id="speedIndicator">1.0x</div>
        <div class="controls-info">
            I : Play<br>
            O : Pause<br>
            j : Jump back 5s<br>
            l : Jump ahead 5s<br>
            [ : Decrease speed<br>
            ] : Increase speed<br>
            = : Reset speed
        </div>
        <video id="videoElement" controls>
            Your browser does not support the video tag.
        </video>
        <div class="subtitle-overlay" id="subtitleOverlay"></div>
    </div>

    <div class="transcript-container">
        <h2>Transcript</h2>
        <div class="transcript" id="transcript">
            <!-- The transcript will be dynamically populated here -->
        </div>
    </div>

    <script>
        let video = document.getElementById("videoElement");
        let subtitleOverlay = document.getElementById("subtitleOverlay");
        let transcriptContainer = document.getElementById("transcript");
        let videoFileInput = document.getElementById("videoFileInput");
        let subtitleFileInput = document.getElementById("subtitleFileInput");
        let speedIndicator = document.getElementById("speedIndicator");

        let srtData = [];
        let currentSubtitleIndex = 0;
        let isPaused = false;

        // Handle keyboard controls
        document.addEventListener('keydown', function(e) {
            switch(e.key.toLowerCase()) {
                case 'i':
                    e.preventDefault();
                    video.play();
                    break;
                case 'o':
                    e.preventDefault();
                    video.pause();
                    break;
                case 'j':
                    video.currentTime = Math.max(0, video.currentTime - 5);
                    break;
                case 'l':
                    video.currentTime = Math.min(video.duration, video.currentTime + 5);
                    break;
                case '[':
                    if (video.playbackRate > 0.25) {
                        video.playbackRate = Math.max(0.25, video.playbackRate - 0.25);
                        updateSpeedIndicator();
                    }
                    break;
                case ']':
                    if (video.playbackRate < 4) {
                        video.playbackRate = Math.min(4, video.playbackRate + 0.25);
                        updateSpeedIndicator();
                    }
                    break;
                case '=':
                    video.playbackRate = 1;
                    updateSpeedIndicator();
                    break;
            }
        });

        function updateSpeedIndicator() {
            speedIndicator.textContent = video.playbackRate.toFixed(2) + 'x';
        }

        function formatTimestamp(timeStr) {
            let parts = timeStr.split(/[:,.]/);
            let hours = parts[0].padStart(2, '0');
            let minutes = parts[1].padStart(2, '0');
            let seconds = parts[2].padStart(2, '0');
            let milliseconds = parts[3].padStart(3, '0');
            return `${hours}:${minutes}:${seconds}.${milliseconds}`;
        }

        function parseSRT(srtText) {
            let lines = srtText.split('\n');
            let subtitles = [];
            let currentSubtitle = null;

            for (let i = 0; i < lines.length; i++) {
                let line = lines[i].trim();

                if (!line) {
                    if (currentSubtitle) {
                        subtitles.push(currentSubtitle);
                        currentSubtitle = null;
                    }
                    continue;
                }

                if (/^\d+$/.test(line)) {
                    if (currentSubtitle) {
                        subtitles.push(currentSubtitle);
                    }
                    currentSubtitle = {
                        index: parseInt(line),
                        text: ''
                    };
                } else if (line.includes('-->')) {
                    let [start, end] = line.split('-->').map(t => t.trim());
                    if (currentSubtitle) {
                        currentSubtitle.startTime = start;
                        currentSubtitle.endTime = end;
                    }
                } else if (currentSubtitle) {
                    currentSubtitle.text = currentSubtitle.text
                        ? currentSubtitle.text + ' ' + line
                        : line;
                }
            }

            if (currentSubtitle) {
                subtitles.push(currentSubtitle);
            }

            return subtitles;
        }

        function populateTranscript() {
            transcriptContainer.innerHTML = '';
            srtData.forEach((sub, index) => {
                let div = document.createElement("div");
                div.className = "transcript-entry";
                div.setAttribute('data-index', index);
                div.innerHTML = `<strong>${formatTimestamp(sub.startTime)} - ${formatTimestamp(sub.endTime)}</strong><br>${sub.text}`;

                div.onclick = function() {
                    let startTime = timeToMilliseconds(sub.startTime) / 1000;
                    video.currentTime = startTime;
                };

                transcriptContainer.appendChild(div);
            });
        }

        function timeToMilliseconds(timeStr) {
            let [time, ms] = timeStr.split(',');
            let [hours, minutes, seconds] = time.split(':');
            return (parseInt(hours) * 3600000 +
                    parseInt(minutes) * 60000 +
                    parseInt(seconds) * 1000 +
                    parseInt(ms));
        }

        function updateSubtitleOverlay() {
            if (srtData.length === 0) return;

            let currentTime = video.currentTime * 1000;
            let subtitle = getCurrentSubtitle(currentTime);

            if (subtitle) {
                subtitleOverlay.textContent = subtitle.text;
                subtitleOverlay.style.display = "block";

                document.querySelectorAll('.transcript-entry').forEach(entry => {
                    entry.classList.remove('current-subtitle');
                });
                let currentEntry = document.querySelector(`.transcript-entry[data-index="${srtData.indexOf(subtitle)}"]`);
                if (currentEntry) {
                    currentEntry.classList.add('current-subtitle');
                    currentEntry.scrollIntoView({ behavior: 'smooth', block: 'center' });
                }
            } else {
                subtitleOverlay.style.display = "none";
            }
        }

        function getCurrentSubtitle(currentTime) {
            return srtData.find(sub => {
                let startTime = timeToMilliseconds(sub.startTime);
                let endTime = timeToMilliseconds(sub.endTime);
                return currentTime >= startTime && currentTime <= endTime;
            });
        }

        video.addEventListener('timeupdate', function() {
            if (!isPaused) {
                updateSubtitleOverlay();
            }
        });

        video.addEventListener('pause', function() {
            isPaused = true;
        });

        video.addEventListener('play', function() {
            isPaused = false;
        });

        video.addEventListener('ratechange', function() {
            updateSpeedIndicator();
        });

        videoFileInput.addEventListener("change", function(event) {
            const file = event.target.files[0];
            if (file && file.type.startsWith("video")) {
                const fileURL = URL.createObjectURL(file);
                video.src = fileURL;
            }
        });

        subtitleFileInput.addEventListener("change", function(event) {
            const file = event.target.files[0];
            if (file && file.name.endsWith(".srt")) {
                const reader = new FileReader();
                reader.onload = function(e) {
                    srtData = parseSRT(e.target.result);
                    populateTranscript();
                };
                reader.readAsText(file);
            } else {
                alert("Please select a valid subtitle file (.srt).");
            }
        });
    </script>
</body>
</html>

A screenshot of the interface can be seen below: Screenshot of video player

It accepts mp4, webm, and ogg videos, and srt subtitles.

It's completely offline and super fast. Hope you enjoy using it!