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:

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:

It accepts mp4, webm, and ogg videos, and srt subtitles.
It's completely offline and super fast. Hope you enjoy using it!