Add a Searchable, Click-to-Play Transcript to a Factual-Series Sample
Add a Searchable, Click-to-Play Transcript to a Factual-Series Sample
A click-to-play transcript looks finished the first time a click works. That is also the moment it starts lying to you.
Three different facts get collapsed into the word "playing": where you asked the player to go, whether the player is actually running, and which cue the player has actually reached. A transcript interface that renders one of those and calls it the other two will pass a demo and fail a review. The fix is not more code. It is keeping the three apart, and keeping the transcript and the video pointed at the same piece of evidence.
This is a foundation-level build. It assumes you already have a permitted sample and a checked, timed transcript — the kind of material produced by a paper edit and source-linked assembly workflow, not raw recognition output. It does not cover making the transcript. If a sentence in the transcript is wrong, the interface below will faithfully help someone find the wrong sentence faster.
Everything from here describes a design to build. It has not been run: the fixture, the code and the test list below are a plan, not a record of a successful session. That distinction matters later, when a click handler starts feeling like verification.
Bind the transcript to one identified media version
Start with a fixture on your own machine: one video file, one cue table, ordinary controls playback. No hosting platform, no new transcription pass, no player library. Complexity here hides the exact failure you are trying to catch.
Make the cue table the single source of truth and derive everything else from it. The rows you render and the timed track that drives the highlight both come from the same list of intervals, so they cannot quietly disagree.
A staged sample, cut A, running 96 seconds, with a burned-in timecode and one deliberate silent gap:
const RELEASE = {
versionId: "sample-cut-A",
src: "/media/sample-cut-A.mp4",
duration: 96.0,
cues: [
{ id: "c01", start: 3.0, end: 11.5, text: "The depot opens before five." },
{ id: "c02", start: 12.0, end: 19.5, text: "Most of the drivers start here and finish somewhere else." },
{ id: "c03", start: 20.0, end: 41.0, text: "By seven the yard is empty and the first bus is already forty minutes out." },
// 41.0 to 46.5 — nothing is said
{ id: "c04", start: 46.5, end: 52.0, text: "The return run starts after the school pickup." },
{ id: "c05", start: 52.0, end: 58.0, text: "The route only pays for itself twice a day." },
{ id: "c06", start: 56.5, end: 60.0, text: "Twice a day, and one of those is for the school." },
{ id: "c07", start: 62.0, end: 74.5, text: "Between those two runs the driver waits at the terminus." },
{ id: "c08", start: 75.0, end: 90.0, text: "That wait is the part the schedule does not show." },
{ id: "c09", start: 91.0, end: 96.0, text: "Same route tomorrow, same times." }
]
};
Two features are deliberate. The gap between 41.0 and 46.5 is real silence that must survive as silence. The overlap between c05 and c06 is two speakers talking across each other, which is ordinary in factual material and lethal to any interface that assumes one passage is active at a time.
Validate the intervals before you wire up a single control, and make the validator report rather than repair:
function checkCues(cues, duration) {
const notes = [];
const seen = new Set();
let previousEnd = 0;
for (const cue of cues) {
if (seen.has(cue.id)) notes.push(`${cue.id}: duplicate id`);
seen.add(cue.id);
if (!(cue.end > cue.start)) notes.push(`${cue.id}: end is not after start`);
if (cue.start < 0 || cue.end > duration) notes.push(`${cue.id}: outside the media timeline`);
if (cue.start < previousEnd) {
notes.push(`${cue.id}: overlaps the previous cue by ${(previousEnd - cue.start).toFixed(1)}s`);
} else if (cue.start > previousEnd) {
notes.push(`${cue.id}: ${(cue.start - previousEnd).toFixed(1)}s with no cue`);
}
previousEnd = Math.max(previousEnd, cue.end);
}
return notes;
}
Run against cut A, this reports the 5.5-second gap before c04 and the 1.5-second overlap at c06. Both are correct. A validator that rejects overlaps would have deleted a real one, which is why it returns notes instead of a boolean. If a report surprises you, the transcript is wrong or the timings drifted — either way, fix that before building the interface. A polished interface on a transcript from another cut is a very convincing way to be wrong.
Store the version identity next to the cue table and check it at load. A recorded file hash plus duration is a reasonable version token for a local fixture. Duration alone is weak: a recut can keep the same length and still move every passage, and comparing seconds would never notice. The last section shows what the recorded token is for.
Make each passage operable without a mouse
Render each passage as a native <button> with visible focus, wrapped in a list item. Not a clickable <div>, not a link. A link promises navigation to somewhere else; this control does something here, and a button says so to the browser and to whatever is driving it.
The control does exactly two things, in order:
async function goToPassage(cueId) {
const cue = cuesById.get(cueId);
if (!cue) return setStatus("That passage is not in this transcript.");
if (video.readyState === 0) {
return setStatus("The sample has not loaded yet. Try again in a moment.");
}
video.currentTime = cue.start; // a request, not an arrival
setStatus(`Asked the player for ${format(cue.start)}.`);
try {
await video.play(); // because a control asked, and only then
setStatus(`Playing from ${format(cue.start)}.`);
} catch (error) {
setStatus(`The player would not start (${error.name}). Use the video controls below.`);
}
}
Assigning currentTime is expressed in seconds and seeks the media, but the browser may reduce the precision it keeps or returns, and the media may resolve to a nearby point in its own timeline. So the assignment is a request. Do not write "playing from 00:52" the instant you set a number — that message describes an intention, not the player.
play() returns a promise, and that promise can be rejected: playback policies can block a request, and a source can fail. Waiting for it is what separates "we asked" from "it started." A request triggered by a button activation is a user gesture, which is exactly when a play request belongs. Do not call play() from a cue event, a timer, or a restored state — those have no gesture behind them, and the rejection you get back is the browser telling you so.
Do not arm the controls before the element knows its own timeline. An assignment to currentTime made before metadata has loaded does not do what a click handler expects; the value can be recorded as a playback start position instead of initiating a seek. Wait for loadedmetadata, and confirm the behavior in the browser you are actually testing rather than trusting any description of it, including this one.
When the media is missing, or a cue's start falls outside the sample, say so in plain words and leave the ordinary <video controls> element visible. An unavailable enhancement is a recoverable state, not a broken page.
Indicate the cue the player actually reaches
Give the sample a timed track whose cue identifiers are the same strings as your row IDs — c01, c02, and so on. That identifier is the only thing tying a row to a cue, so keep it identical in both places.
WEBVTT
c01
00:00:03.000 --> 00:00:11.500
The depot opens before five.
c04
00:00:46.500 --> 00:00:52.000
The return run starts after the school pickup.
Note that nothing sits between 41.0 and 46.5. You are not obliged to ship a separate file; you can create cues in script and add them to a track instead. Either way, the track is what produces the event, and the event is what tells you where the player is.
A <track> element you have not enabled has a disabled mode, and a disabled track does not load its cues — so nothing fires. Set the mode yourself (a hidden track gives you cues for scripting without drawing them over the picture) and check the mode and the loaded cue count in your browser before blaming your handler.
track.addEventListener("cuechange", () => {
const active = track.activeCues; // may be null
const current = new Set();
for (let i = 0; active && i < active.length; i++) {
current.add(active[i].id); // a cue list is not an Array
}
for (const row of rows) {
row.toggleAttribute("data-active", current.has(row.dataset.cueId));
}
state.activeCues = [...current];
});
Three things this deliberately does.
It reads activeCues, not the last row someone clicked. Those diverge constantly: someone drags the scrubber, restarts the file, or jumps by keyboard. The click tells you what was requested. activeCues tells you what the player reached. The indicator must answer the second question.
It handles the empty and the crowded case as normal. During the gap, current is empty and no row is marked — that is a true statement about the sample, not a bug. During the overlap, two rows are marked at once. Resist the urge to pick a winner; if you must order them for some other purpose, order by start time and mark both.
It changes an attribute on elements that already exist. It does not rebuild the row. Re-rendering on every cue change would drop any text selection in progress and churn whatever is reading the page. And note what is absent from the handler entirely: no scrollIntoView. Where the reader has put their eyes and their keyboard position is their business. An indicator change is not permission to move either.
cuechange fires when the active set changes, which happens during playback and also when a seek lands somewhere else. The highlight follows the player wherever the player goes, including places it was not sent.
Let reading and playback proceed independently
Search on this fixture should be a plain text filter over the cue list, applied in place, preserving source order and cue identifiers. Someone looking for "school" wants to see that it appears at c04 and again at c06, and to see that those two are four seconds apart. A relevance re-ranking destroys exactly that relationship, which is the one a reviewer is usually after. Keep the ID visible in the row so a note can say "c05" and mean something.
Two rules keep search and playback from fighting.
Searching does not move the reader. Filtering the list while the video plays on is fine. Forcing the matching row to scroll into view while someone is mid-selection, or has scrolled to a different result on purpose, is not.
The highlight does not grab the view either. Instead of a scrolling side effect in the cuechange handler, give the reader an explicit control: return to current passage. That button is where scrolling belongs, because a person asked for it.
That button has one edge case worth handling now. If the active cue is filtered out by the current search, scrolling to it scrolls to nothing. Clearing the field is not enough by itself: assigning to searchBox.value dispatches no input event, so a filter that runs from the input listener never re-runs, the matching row is still absent from the document, and the scroll does nothing while the status line reports that it showed you the passage. Clear the field, then re-apply the filter through the same function the input listener calls:
function returnToCurrent() {
const active = state.activeCues;
if (active.length === 0) return setStatus("The player is between passages.");
if (searchBox.value) {
searchBox.value = ""; // clearing the field fires nothing
applyFilter(searchBox.value); // so re-run the filter the input listener runs
}
const row = document.querySelector(`[data-cue-id="${active[0]}"]`);
if (row) row.scrollIntoView({ block: "center" });
setStatus(active.length > 1 ? `Two passages are active: ${active.join(", ")}.` : `Showing ${active[0]}.`);
}
Underneath all of it, keep the two things that work when the script does not: the full plain transcript, rendered as ordinary selectable text with its timestamps written out, and the native <video controls> element. The interactive layer is an addition. Losing it should return the reader to a slightly more tedious version of the same job, never to a blank panel. A descriptive alternative for people who will never play the video is a separate artifact on purpose — it stays useful precisely when this one fails, so do not fold it into the player.
Test the cases a successful click conceals
A click that works once proves that one path works once. Here is what to walk through, and what each check can and cannot tell you.
| Check | What it should show | What it cannot tell you |
|---|---|---|
| Click before metadata has loaded | A status message; native controls still usable | Whether your readyState guard is placed correctly in every path |
| Seek to c07 while paused | currentTime moves, one row marks, paused stays true |
Sample-accurate alignment |
| Play through 41.0 to 46.5 | No row marked; "between passages" | Whether the gap was intended — only your checked transcript knows that |
| Play through 56.5 to 58.0 | Two rows marked | Which speaker is "primary" |
| Block playback, then click | Rejection handled, native controls offered | Why the policy blocked it |
| Swap in a recut and click c05 | A version mismatch reported; with the check bypassed, the click and the highlight agreeing on c05 while the audio plays c06 | Which artifact — transcript or video — is the one you meant |
That last row deserves the space. Take cut B: the same sample with 4.5 seconds trimmed out of the silent gap, now 91.5 seconds long. Every cue from c04 onward still carries cut A's timings. The version check should refuse to arm the controls, and if you bypass it to see what happens, the failures are instructive precisely because they are uneven.
Click c04 and you land at A-time 51.0, one second before that passage ends. You hear the tail of the right line. Click c05 and you land at A-time 56.5, which is the start of c06 — you asked for one speaker and got the other. Click c09 and you land half a second before the end of the sample. A 4.5-second drift disappears inside a twelve-second passage and destroys a short one, which is why "the highlight moved, so the sync must be fine" is not a test. Read the burned-in timecode. Listen for the opening words of the passage you asked for. Verify the landing, not the assignment.
When you do find a mismatch, report it. Do not shift the cue times to match the recut. Silently correcting the timings would edit the evidence, which is the one thing this interface exists to avoid, and it would hide the version problem from everyone downstream.
Record the browser and the route you tested — which build, how the file was loaded, which version of the sample. Whatever the result, this remains a limited review interface for a small team. It is not an accessibility certification, and the choices made here — a button per passage, an aria-current-style marker on the active row, the decision not to announce cue changes automatically — are candidates for proper keyboard and assistive-technology review, not conclusions. A polite live region firing on every cue change during ordinary playback would read the transcript aloud over the transcript, which is why announcing belongs to user-initiated changes only. Whether that holds up in practice is exactly what the review is for.
The round trip to aim for is small and checkable: a reader tabs to the passage labeled c05, activates it, and the player both starts and is marked as sitting inside c05 — requested, playing and current all agreeing, and all three visible as separate facts. Beside it, keep the failure: click c05 against cut B. The row you clicked lights up, the status line reports the player starting from c05, and the words you hear belong to c06 — the passage sitting at A-time 56.5. Requested, playing and current agree with one another, and every one of them is wrong about the evidence. The highlight stays on c05 because the track drawing it comes from the same stale cue table: at 52.0, cut A's intervals still name c05. Regenerate that track from cut B's intervals while the rows keep cut A's, and the same click lands at 52.0 inside two cues at once — c05 and c06 both marked. Neither version of the failure announces itself. Underneath both, the plain transcript and the ordinary video controls are still there, still selectable, still usable. That is the whole bargain. The transcript and the player point at the same evidence, and neither of them takes the reader's place away.
Frequently asked questions
Why should requested, playing, and current be kept separate in a click-to-play transcript?
Because the word 'playing' collapses three different facts: where you asked the player to go, whether the player is actually running, and which cue the player has actually reached. Assigning currentTime is a request, not an arrival; the browser may reduce precision or resolve to a nearby point. play() returns a promise that can be rejected by playback policies or a failed source. An interface that renders one of those and calls it the other two will pass a demo and fail a review.
What should happen before metadata has loaded?
Do not arm the controls before the element knows its own timeline. An assignment to currentTime made before metadata has loaded does not do what a click handler expects; the value can be recorded as a playback start position instead of initiating a seek. Wait for loadedmetadata, and confirm the behavior in the browser you are actually testing. If readyState is 0, a status message such as 'The sample has not loaded yet' is appropriate, and the native controls should remain usable.
How should silent gaps and overlapping cues be shown?
During the gap, current is empty and no row is marked—that is a true statement about the sample, not a bug. During the overlap, two rows are marked at once; resist the urge to pick a winner, or if you must order them, order by start time and mark both. The validator should report gaps and overlaps rather than repair them, because a real overlap is ordinary in factual material. A validator that rejects overlaps would delete a real one.
How do I keep search from fighting playback?
Search should be a plain text filter over the cue list, applied in place, preserving source order and cue identifiers. Searching does not move the reader, and the highlight does not grab the view; instead, give the reader an explicit 'return to current passage' control for scrolling. If the active cue is filtered out, clearing the field is not enough by itself: assigning to searchBox.value dispatches no input event, so re-apply the filter through the same function the input listener calls.
What does a recut expose, and how should I report it?
A recut can keep a similar length while moving every passage, so duration alone is a weak version token. In the cut B example, 4.5 seconds trimmed from the silent gap leaves every cue from c04 onward carrying cut A’s timings. Click c04 and you land one second before that passage ends; click c05 and you land at the start of c06; click c09 and you land half a second before the end. The version check should refuse to arm the controls, and if you bypass it, report the mismatch. Do not shift the cue times to match the recut; silently correcting the timings would edit the evidence and hide the version problem. Record the browser and route you tested; this remains a limited review interface, not an accessibility certification.