commit f2b4f358f29e31b1f59788588548222d43051ffd
parent a8e506b28987a7f621d9773d4de051f401dc281f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 09:55:00 -0400
search + viewer: every English track is searched, and the reader switches tracks
A transcripts leaf matches a record's alternate tracks too (hitsAcrossTracks,
lib/captionTracks.ts): a word every track says is found once, in the primary;
a word only an alternate holds is found there and the hit carries its track.
The result row's badge says "in uploaded captions", and opening the hit opens
the reader on that track at that moment.
The transcript reader gets a small "Track:" switcher beside the mode strip
when a record has an alternate whose words differ. It is a viewer's choice: a
`vt` URL param (absent for the primary, carried by Share), never a change to
the record. Downloads and Copy MD take the track on show.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 174 insertions(+), 16 deletions(-)
diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx
@@ -25,6 +25,7 @@ import { cuesToSrt, cuesToText } from "../lib/vtt";
import { transcriptToMarkdown } from "../lib/transcriptToMarkdown";
import type { Platform } from "../lib/transcripts";
import { vodExpiry } from "../lib/vodExpiry";
+import { cuesOfTrack, recordTracks, type TrackFields } from "../lib/captionTracks";
import { VodExpiredBadge } from "./badges";
import type { RumblePlayerHandle } from "./RumblePlayer";
import type { OdyseePlayerHandle } from "./OdyseePlayer";
@@ -92,7 +93,15 @@ export type TranscriptData = {
webpageUrl: string;
hlsUrl?: string;
mediaUrl?: string;
+ // The cues of the track on show: the primary, or the alternate the viewer
+ // picked (`track`).
cues?: Cue[];
+ // The record's tracks, primary first (lib/captionTracks.ts) — more than one
+ // only when an alternate's words differ from the primary's. Empty when the
+ // record names none.
+ tracks: string[];
+ // The track on show; null when the record names no tracks.
+ track: string | null;
};
type Status = "idle" | "loading" | "ready";
@@ -117,7 +126,7 @@ type Detail = {
hlsUrl?: string;
mediaUrl?: string;
cues?: Cue[];
-};
+} & TrackFields;
type ClipState = {
slug: string | null;
@@ -164,8 +173,11 @@ type PlayerState = {
openTranscript: (
slug: string,
start?: number,
- opts?: { mode?: ModalMode },
+ opts?: { mode?: ModalMode; track?: string },
) => void;
+ // Show another of the record's tracks (null: the primary). A viewer's
+ // choice — it changes the URL (`vt`), never the record.
+ setViewTrack: (track: string | null) => void;
setDisplayMode: (mode: DisplayMode) => void;
setModalMode: (mode: ModalMode) => void;
closePlayer: () => void;
@@ -395,7 +407,7 @@ export function PlayerProvider({
children: React.ReactNode;
features?: PlayerFeatures;
}) {
- const { v: urlSlug, t: urlTime, vm: urlVm } = useUrlParams();
+ const { v: urlSlug, t: urlTime, vm: urlVm, vt: urlVt } = useUrlParams();
const [core, dispatch] = useReducer(coreReducer, INITIAL_CORE);
const { detail, displayState, clip, chat, digest } = core;
const [playing, setPlaying] = useState(false);
@@ -450,9 +462,15 @@ export function PlayerProvider({
webpageUrl: detail.webpageUrl,
hlsUrl: detail.hlsUrl,
mediaUrl: detail.mediaUrl,
- cues: detail.cues,
+ // A `vt` the record does not hold reads as the primary.
+ cues: cuesOfTrack(detail, urlVt) ?? detail.cues,
+ tracks: recordTracks(detail),
+ track:
+ urlVt && cuesOfTrack(detail, urlVt) !== undefined
+ ? urlVt
+ : (detail.track ?? null),
};
- }, [detail, detailMatches]);
+ }, [detail, detailMatches, urlVt]);
const status: Status = !activeSlug
? "idle"
: detailMatches
@@ -463,7 +481,7 @@ export function PlayerProvider({
const clipEnd = clip.slug && clip.slug === activeSlug ? clip.end : null;
const openTranscript = useCallback(
- (slug: string, start?: number, opts?: { mode?: ModalMode }) => {
+ (slug: string, start?: number, opts?: { mode?: ModalMode; track?: string }) => {
dispatch({ type: "DISPLAY_SET", slug, mode: "modal" });
const t = typeof start === "number" ? Math.round(start) : null;
if (slug === activeSlug && t !== null && playerRef.current) {
@@ -475,11 +493,18 @@ export function PlayerProvider({
v: slug,
t,
vm: opts?.mode ?? "transcript",
+ // A hit from an alternate track opens on that track; anything else
+ // opens on the primary.
+ vt: opts?.track ?? null,
});
},
[activeSlug],
);
+ const setViewTrack = useCallback((track: string | null) => {
+ writeUrlParams({ vt: track });
+ }, []);
+
const setModalMode = useCallback((mode: ModalMode) => {
writeUrlParams({ vm: mode });
}, []);
@@ -494,7 +519,7 @@ export function PlayerProvider({
const closePlayer = useCallback(() => {
dispatch({ type: "DISPLAY_CLEAR" });
- writeUrlParams({ v: null, t: null, vm: "transcript" });
+ writeUrlParams({ v: null, t: null, vm: "transcript", vt: null });
}, []);
const markClipStart = useCallback(() => {
@@ -542,6 +567,10 @@ export function PlayerProvider({
// Carry the current panel so a shared link reopens what the sharer was
// looking at. "transcript" is the default and stays absent from the URL.
if (modalMode !== "transcript") params.set("vm", modalMode);
+ // And the track, when it is not the primary.
+ if (data.track && data.tracks.length > 1 && data.track !== data.tracks[0]) {
+ params.set("vt", data.track);
+ }
const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
try {
await navigator.clipboard.writeText(url);
@@ -688,6 +717,9 @@ export function PlayerProvider({
hlsUrl: full.hlsUrl,
mediaUrl: full.mediaUrl,
cues: full.cues,
+ ...(full.altTracks?.length
+ ? { track: full.track, altTracks: full.altTracks }
+ : {}),
},
});
})
@@ -922,6 +954,7 @@ export function PlayerProvider({
clipStart,
clipEnd,
openTranscript,
+ setViewTrack,
setDisplayMode,
setModalMode,
closePlayer,
@@ -948,6 +981,7 @@ export function PlayerProvider({
clipStart,
clipEnd,
openTranscript,
+ setViewTrack,
setDisplayMode,
setModalMode,
closePlayer,
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -45,6 +45,7 @@ import {
} from "./SearchSessionContext";
import { useSearchData } from "./SearchDataContext";
import type { PublishedTag } from "../lib/curatedTags";
+import { inTrackLabel } from "../lib/captionTracks";
// Rough first-paint guess for one full result card (header + a couple of
// leaf sections + a handful of hits). After mount, ResizeObserver measures
@@ -969,7 +970,9 @@ function PostBadge({ platform }: { platform: string }) {
}
function TrackBadge({ track }: { track: string }) {
- const label = track === "live_chat" ? "live chat" : track;
+ // An alternate English track of a transcript (lib/captionTracks.ts) says
+ // which one: "in uploaded captions".
+ const label = track === "live_chat" ? "live chat" : inTrackLabel(track);
return (
<span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground self-center">
{label}
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -1925,7 +1925,12 @@ function useSearchSessionState() {
: hit?.scope === "chat"
? "chat"
: "transcript";
- openTranscript(slug, hit?.start, { mode: modalMode });
+ // A transcript hit from an alternate track (lib/captionTracks.ts) opens
+ // the reader on that track, where its words are.
+ openTranscript(slug, hit?.start, {
+ mode: modalMode,
+ ...(modalMode === "transcript" && hit?.track ? { track: hit.track } : {}),
+ });
},
[openTranscript],
);
diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx
@@ -36,6 +36,7 @@ import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
import { VirtualRow } from "./VirtualRow";
import { formatTimestamp } from "../lib/vtt";
import { formatDate } from "../lib/format";
+import { trackLabel } from "../lib/captionTracks";
type DisplayCue = {
start: number;
@@ -53,6 +54,7 @@ export default function TranscriptModal() {
status,
modalMode,
setModalMode,
+ setViewTrack,
chatCues,
chatStatus,
modalNotice,
@@ -320,7 +322,22 @@ export default function TranscriptModal() {
)}
</div>
- <div className="px-3 sm:px-0">{modeStrip}</div>
+ <div className="px-3 sm:px-0 flex flex-wrap items-center gap-x-3 gap-y-1">
+ {modeStrip}
+ {/* The record's other English tracks (lib/captionTracks.ts), only
+ where one's words differ from the primary's. A viewer's choice:
+ it swaps the cues on show and changes nothing else. */}
+ {!isChat && !isDigest && data && data.tracks.length > 1 && (
+ <TrackSwitcher
+ tracks={data.tracks}
+ track={data.track ?? data.tracks[0]}
+ onChange={(t) => {
+ scrollKindRef.current = "smooth";
+ setViewTrack(t === data.tracks[0] ? null : t);
+ }}
+ />
+ )}
+ </div>
{modalNotice && (
<p
@@ -793,3 +810,34 @@ function findActiveIndex(
}
return found;
}
+
+// "Track: original audio captions ▾" — small and quiet, beside the mode strip.
+// The first track is the primary (the default); its label says so in the menu.
+function TrackSwitcher({
+ tracks,
+ track,
+ onChange,
+}: {
+ tracks: string[];
+ track: string;
+ onChange: (track: string) => void;
+}) {
+ return (
+ <label className="inline-flex items-center gap-1.5 text-xs text-zinc-400 pointer-events-auto">
+ <span>Track:</span>
+ <select
+ data-testid="track-switcher"
+ value={track}
+ onChange={(e) => onChange(e.target.value)}
+ className="bg-transparent text-zinc-200 rounded border border-zinc-700 px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-zinc-500"
+ >
+ {tracks.map((t, i) => (
+ <option key={t} value={t} className="bg-zinc-900">
+ {trackLabel(t)}
+ {i === 0 ? " (default)" : ""}
+ </option>
+ ))}
+ </select>
+ </label>
+ );
+}
diff --git a/common/components/urlState.ts b/common/components/urlState.ts
@@ -26,6 +26,10 @@ export type UrlParams = {
v: string | null;
t: number | null;
vm: ModalMode;
+ // The transcript TRACK the reader shows (lib/captionTracks.ts) when it is
+ // not the record's primary — a viewer's choice, never a data change. Absent
+ // (null) for the primary, which is the default and stays off the URL.
+ vt: string | null;
ch: string[];
nov: boolean;
nol: boolean;
@@ -94,6 +98,7 @@ function parse(search: string): UrlParams {
v: p.get("v"),
t: t !== null && Number.isFinite(t) ? t : null,
vm,
+ vt: p.get("vt") || null,
ch: p.getAll("ch"),
nov: p.get("nov") === "1",
nol: p.get("nol") === "1",
@@ -126,6 +131,7 @@ type Patch = Partial<{
v: string | null;
t: number | null;
vm: ModalMode;
+ vt: string | null;
ch: string[];
nov: boolean;
nol: boolean;
@@ -165,6 +171,10 @@ export function writeUrlParams(patch: Patch) {
// setting vm=transcript — the default must stay absent from the URL.
else params.delete("vm");
}
+ if (patch.vt !== undefined) {
+ if (patch.vt) params.set("vt", patch.vt);
+ else params.delete("vt");
+ }
if (patch.ch !== undefined) {
params.delete("ch");
for (const v of patch.ch) params.append("ch", v);
diff --git a/common/lib/search/evalTree.test.ts b/common/lib/search/evalTree.test.ts
@@ -374,3 +374,22 @@ test("a non-empty curated-tag selection makes a filter selective", () => {
// a tag-only query would plan a full scan.
assert.equal(filterIsSelective(openFilters({ curatedTags: ["eva-collab"] })), true);
});
+
+test("a transcripts leaf reads the record's alternate tracks; a hit only one holds wears its track", () => {
+ const r = evalLeaf(
+ newLeaf({ id: "l", query: "zeppelin" }),
+ { scope: "transcripts", test: (t) => t.includes("zeppelin") || t.includes("bridge") },
+ ctx({
+ cues: [cue(3, "the bridge")],
+ altTracks: [{ track: "en", cues: [cue(4, "the bridge"), cue(100, "a zeppelin")] }],
+ }),
+ );
+ assert.equal(r.count, 2, "the bridge once (primary), the zeppelin once (en)");
+ assert.deepEqual(
+ r.hits.map((h) => [h.seconds, h.track]),
+ [
+ [3, undefined],
+ [100, "en"],
+ ],
+ );
+});
diff --git a/common/lib/search/evalTree.ts b/common/lib/search/evalTree.ts
@@ -32,6 +32,7 @@ import {
import { matchAliases, type SearchAlias } from "../searchAliases";
import { VIDEO_STATES, type VideoState } from "../availability";
import type { Cue } from "../vtt";
+import { hitsAcrossTracks, type AltTrack } from "../captionTracks";
import { clock, truncate, type Matcher } from "./window";
export type { Matcher };
@@ -131,6 +132,9 @@ export type RecordCtx = {
description: string;
tags: string;
cues: Cue[];
+ // The record's alternate English tracks (lib/captionTracks.ts), when it has
+ // any: the transcripts scope reads them too.
+ altTracks?: AltTrack[];
chatCues: Cue[];
// The post body, when this record IS a post rather than a video. Empty for a
// video record, so a posts-scope leaf never matches one.
@@ -159,11 +163,16 @@ export function evalLeaf(
let count = 0;
switch (m.scope) {
case "transcripts":
- for (const cue of ctx.cues) {
- if (!m.test(cue.text)) continue;
+ // Every English track of the record (lib/captionTracks.ts): a match only
+ // an alternate holds wears that alternate's track id.
+ for (const cue of hitsAcrossTracks(
+ { cues: ctx.cues, altTracks: ctx.altTracks },
+ (cues) => cues.filter((c) => m.test(c.text)),
+ )) {
count++;
push({
scope: "transcripts",
+ ...(cue.track ? { track: cue.track } : {}),
clock: clock(cue.start),
seconds: cue.start,
text: truncate(cue.text, max),
diff --git a/common/lib/search/leafPipeline.test.ts b/common/lib/search/leafPipeline.test.ts
@@ -284,3 +284,28 @@ test("a regex leaf compiles once; a malformed pattern matches nothing", async ()
const bad = await run(newLeaf({ query: "n([edle", useRegex: true }), ["c/v1"], f);
assert.deepEqual(bad.slugs, []);
});
+
+test("a transcripts leaf reads a record's alternate tracks too, and names the track of a hit only one holds", async () => {
+ const rec = {
+ ...record("v1", [{ start: 3, text: "the harbor bridge" }]),
+ track: "en-orig",
+ altTracks: [
+ {
+ track: "en",
+ cues: [
+ { start: 4, end: 6, text: "the harbor bridge" },
+ { start: 100, end: 102, text: "a zeppelin overhead" },
+ ],
+ },
+ ],
+ } as TranscriptDetail;
+ const a = archive({ pages: [[rec, record("v2", [{ start: 0, text: "nothing" }])]] });
+ const only = await run(newLeaf({ query: "zeppelin" }), ["c/v1", "c/v2"], fetchersFor(a));
+ assert.deepEqual(only.slugs, ["c/v1"]);
+ const hits = only.hits.get("c/v1") as { start: number; track?: string; scope: string }[];
+ assert.deepEqual(hits.map((h) => [h.start, h.track, h.scope]), [[100, "en", "transcripts"]]);
+ // A word both tracks say near the same moment: one hit, the primary's.
+ const both = await run(newLeaf({ query: "harbor" }), ["c/v1"], fetchersFor(a));
+ const bh = both.hits.get("c/v1") as { track?: string }[];
+ assert.deepEqual(bh.map((h) => h.track), [undefined]);
+});
diff --git a/common/lib/search/leafPipeline.ts b/common/lib/search/leafPipeline.ts
@@ -25,6 +25,7 @@ import type { SubsDetail } from "../subs";
import type { Post } from "../posts";
import type { LayerScope, LeafNode } from "../searchQuery";
import { findHitsInCues, findHitsInText, type Hit, type SubsHit } from "./window";
+import { hitsAcrossTracks } from "../captionTracks";
export type { Hit, SubsHit };
@@ -163,9 +164,10 @@ export function createSearchPipeline(
useRegex,
regex,
)
- : full.cues
- ? findHitsInCues(full.cues, query, useRegex, regex, remaining)
- : [];
+ : // Every English track of the record (lib/captionTracks.ts).
+ hitsAcrossTracks(full, (cues) =>
+ findHitsInCues(cues, query, useRegex, regex, remaining),
+ );
if (hits.length > 0) {
localHits[slug] = hits;
totalSoFar += hits.length;
@@ -665,6 +667,7 @@ export function runLeafPipeline(opts: LeafPipelineOptions): LeafController {
list.map((h) => ({
leafId: leaf.id,
scope: leaf.scope,
+ ...(h.track ? { track: h.track } : {}),
start: h.start,
text: h.text,
})),
diff --git a/common/lib/search/window.ts b/common/lib/search/window.ts
@@ -110,7 +110,9 @@ export function windowedTranscript(
// — on the cue it starts in — and the emitted text widens to the window only
// in that case. Everything else emits the matched cue verbatim.
-export type Hit = { start: number; text: string };
+// `track`: a transcript hit from one of the record's ALTERNATE English tracks
+// (lib/captionTracks.ts, hitsAcrossTracks). Absent for a hit in the primary.
+export type Hit = { start: number; text: string; track?: string };
export type SubsHit = { track: string; start: number; text: string };