Video Watch Page — a page that owns the one player
/videos/watch/<title-slug> renders one video with its transcript beside it and related videos underneath. It replaces TranscriptModal`, a dialog over the grid that mounted its own YouTube
embed and had no URL.
- Where:
apps/debate-ai.com/app/videos/watch/[slug]/page.tsx - Package:
debate-videos—panels/watch/VideoWatchPage.tsx,components/watch/*,lib/video-slug.ts
The slug
lib/video-slug.ts owns both directions:
videoWatchSlug("2022 NDT Finals") → "2022-ndt-finals"
parseVideoWatchSlug("2022-ndt-finals") → "2022-ndt-finals"The slug is purely the slugified title — no video id is appended, so a retitled video gets a new address.
/videos/[category] is one segment and /videos/watch/[slug] is two, so
they never collide.
Only one embed exists at a time
Two YouTube embeds on one page fight over playback and over picture-in-picture, which is why the library has exactly one player, mounted above the route tree. This page does not add a second one — it takes over from the first:
mount → setActiveVideo(id, …) share title, meta, queue, playback rate
→ setTheaterVideoId(id) PersistentVideoPlayer renders null
→ videoPlayerIframeRef = this sendYouTubeCommand drives this embed
unmount → setActiveVideo(id, …, seconds) hand the position back
→ setTheaterVideoId(null) the floating player resumes itPersistentVideoPlayer returns null while theaterVideoId is set, but
its hooks stay mounted above that early return. That is deliberate: the
infoDelivery messages this page's embed posts are window-wide, so the
store's play state and the localStorage position keep updating while the
user is on the watch page.
Two guards matter on the way out, both for the case where the next page is another watch page that has already mounted:
- the position is only written back if the store still names this video;
theaterVideoIdis only cleared if it still names this video.
Without the second, the floating player would reappear behind the incoming watch page.
Switching videos is a navigation
Anything that changes the store's active video — a related card, the
queue advancing — pushes that video's watch URL instead of swapping the
embed, so the address always names what is playing. The effect reads
useVideoPlayerStore.getState() rather than the render's snapshot,
because on the first commit the snapshot still names whatever was playing
before the page opened and would bounce straight back to it.
Shared controls
components/video-player/PlayerIconButton.tsx is the single button both
toolbars are built from — PlayerControls (popout, size="sm") and
WatchToolbar (page, size="md"). SlowSpreadButton draws itself the
same way and takes the size from whichever toolbar hosts it.
test/watch-toolbar.test.tsx renders both strips and asserts the shared
accessible names, so a rename in one shows up as a failure rather than as
drift.
Data
The page is server-rendered so the title, description and social card come
from the video rather than from a client fetch a crawler never waits for.
lib/videos/video-repository.ts gained helpers over getVideoPage:
getVideoBySlug(slug)— one video, by its slugified title.getVideoByRouteSlug(season, event, matchup)— one video, by its canonical route segments.getRelatedVideos(video, limit)— passes in order of specificity: the same tournament (searched by its year-stripped name), then the same style or lecture category, then recency; each pass tops the list up.
Transcript search highlighting
WatchTranscriptPanel's search still filters to lines whose text contains
the query, but each surviving line now also highlights where it matched
instead of leaving the reader to find it. transcriptUtils.ts's
splitOnMatch(text, needle) is the pure half: a case-insensitive split of
text around every occurrence of needle into { text, matched }
segments, with an empty/whitespace-only needle short-circuiting to the
whole text unmatched (a cleared search box highlights nothing rather than
matching every position). TranscriptLine — shared with the transcript
modal and the popout player's caption tray, neither of which searches —
takes the query as an optional highlightQuery prop rather than reading it
itself, so only the watch page's own per-word rendering runs splitOnMatch
(on each word already split out for the karaoke sweep) and wraps a matched
segment in <mark>; the other two callers pass nothing and render exactly
as before. Tested in
packages/debate-videos/test/transcript-match-highlight.test.ts: no
needle, a whitespace-only needle, a single match, multiple matches, no
match, a whole-text match, and a needle containing regex metacharacters
(treated as literal text, not a pattern).
By speech
lib/round-speeches.ts's buildRoundSpeeches(documents) lines the
transcript, summary and analysis up into one RoundSpeech per speech:
each document is split on ## only (parseDocumentSections(body, { depth: 2 }), so a summary's ### Plan stays inside its speech), and
each heading goes through identifySpeech, which reads LD/Policy codes
(1AC, 2NR), spelled-out names (First Negative Constructive), PF
speeches (Con Final Focus), cross-ex and crossfire. The match key is
the code plus its occurrence, so the second CX of the transcript is the
second CX of the summary; a heading that names no speech (Decision)
is matched on its lead text, and a document's untitled lead-in becomes
an Overview. The transcript is read first; a speech only a later
document has is inserted before the next already-placed speech that
document mentions, or at the end.
It returns [] unless the summary or analysis itself covers at least two
speeches, so a one-paragraph summary beside a timed transcript does not
drive the view. With { requireSummary: false } a transcript that names
two speeches is enough.
Every round gets the view anyway. lib/round-formats.ts's
resolveRoundSpeeches(documents, style) tries the documents, then — only
for a numeric DebateStyle, so lectures are untouched — the transcript
alone, then standardRoundSpeeches(style): the format's speech order
(Policy and College 12 speeches, LD 7, PF 11), untimed and empty. Its
keys are built through identifySpeech exactly as buildRoundSpeeches
builds them (1AC#1, CX#2), so marks and saved runs still line up if a
summary is written later.
Starts a reader marks with Mark start live in
state/speechStartMarks.ts (per video and speech key, in localStorage,
capped at 2,000; a cache like the outcome runs, not synced).
withSpeechStarts overlays them — a mark wins over a document timecode —
and withCaptionTranscripts gives each timed speech with no written
transcript the caption sentences between its start and the next timed
start.
VideoWatchPage resolves the list once, applies marks and captions, and
hands the same list to WatchSpeechTimeline (under the player) and,
through WatchSidePanel's speeches prop, to WatchRoundPanel.
WatchSidePanel still builds it from the documents itself when no list
is passed. The timeline
talks to the panel through focusSpeech: { key, seq }, where seq
makes a repeat click on the same speech still land. The page mirrors the
embed's reported duration into state for the timeline's proportions;
speechTimelineSegments falls back to equal segments until it is known.
The follow behaviour reuses the side panel's remembered auto-scroll
choice rather than adding a second toggle. Tested in
test/round-speeches.test.ts and test/watch-round-panel.test.tsx.
Outcomes: alternative responses and predicted ballots
WatchRoundPanel offers a fourth view, Outcomes
(components/watch/WatchSpeechOutcomes.tsx), on every RoundSpeech
whose isSpeech is true, when the panel knows the video id. It is split
the same way as debate-practice-rounds' response-outcome charts:
lib/speech-outcomes.tsis pure.buildSpeechOutcomePromptsends the round before the speech (each speech's summary, else analysis, else transcript, clipped to 1,400 characters), the speech itself (its summary, analysis and up to 7,000 characters of transcript), and what actually followed, including the decision. Sending what followed is on purpose: the question is counterfactual, and the later speeches and the real decision are what let the model say whether a different 1AR would have mattered. The video's format, teams, tournament and recorded decision go in too, plus the reader's optional steer. When the speech has nothing of its own, the whole round's captions (clipped to 18,000 characters) stand in.parseSpeechOutcomeResponseaccepts strict JSON or the first object in a fenced reply, keeps the alternatives that parse, and normalizes each ballot. The affirmative's win probability is trusted over a contradictingwinner(the chart draws the number), a fraction such as 0.7 is read as 70, and PF's pro/con map to aff/neg.- Judge panels. The input takes
panel: JudgeLens[](six lenses;normalizePaneldedupes, orders canonically and caps at five). The model returnsballots— one per judge, withrfdanddecisive— for the speech as given and each alternative.aggregatePanelderives the option'sPredictedBallotfrom them rather than asking the model: the winner is the vote majority (an even split goes to the sidemajorityProbabilityfavours),affWinProbabilityis the chance a majority votes aff given each judge's own odds, andrfdis the tally with the dissenters. A panel of one is that judge's ballot. A reply with a single legacyballotstill parses. lib/speech-outcomes-client.tsposts to the shared/api/reason-aiproxy. It does not add a route, and the proxy's sign-in gate applies.outcomeMaxTokensscales the reply budget with the panel size.state/speechOutcomeCache.tskeeps the last run per video, speech key and panel (panelKey, e.g.flow+lay+theory; a lone judge is its bare id, so runs saved before panels still open) inlocalStorage, capped at 40 runs. It is a cache, not a user record, so it is deliberately not added to the account sync.
speakerSwing measures each alternative against the speech as given, for
the side that gave the speech (a neg speech's swing is the negation of the
aff delta; cross-ex has no speaking side and is measured for the aff).
bestAlternativeIndex marks the largest positive swing, if any.
The prompt lives in this package rather than in debate-speech-writer.
Moving it there would give debate-videos a new dependency edge for one
prompt. Tested in test/speech-outcomes.test.ts,
test/round-formats.test.ts and test/watch-round-panel.test.tsx.
Known gaps
- Related videos are query passes, not a relevance model.
A speech's start time can only come from a heading's timecode. There is no way for a viewer to mark where a speech starts.Fixed: Mark start on each speech tab (state/speechStartMarks.ts). Marks stay in the browser that made them; there is no way to share them yet.- A round with no documents uses the format's textbook speech order. A PF round where the con speaks first, or a Policy round that skips a CX, has tabs in the wrong order; marks still time them, but the timeline sorts by start, so a mislabelled tab just reads wrong.
- Cross-examinations are matched between documents by order, so a summary that skips a CX misaligns every later CX.
- Outcome ballots are one model's single guess per run; nothing samples several runs or reports how much they disagree, so a 55% can move to 45% on Run again.
- The Outcomes view sees only the documents and the auto-captions, never the video. Before any start is marked, an unwritten round sends the whole round's captions and leaves the model to find the speech in them by speech order.
- The panel's judges are fixed presets. The standalone speech viewer's per-judge trait sliders and ruling trees are not ported.
Saved outcome runs stay in the browser that made them; they cannot be shared by link, only copied as Markdown.Narrowed:CachedSpeechOutcomenow carries a derived${videoId}::${speechKey}::${lens}id (speechOutcomeCacheId) and is registered indebate-data-sync'sTOOL_RECORD_COLLECTIONSasspeechOutcomeRuns(see Tool Data Sync), so a signed-in user's cached runs follow them to another device instead of re-running — and re-paying for — the same simulation there. Sharing a run with someone else, rather than just this account's other browsers, still only works by copying it as Markdown — there is no share-by-link.The tournament pass was aFixed:LIKEsearch across title/channel/description (the same free-text search the search box uses), so a tournament whose name appeared in an unrelated video's description pulled that video in too.getRelatedVideos' tournament pass now filters onVideoQueryParams.tournament, a dedicated predicate scoped to thetournamentcolumn/field alone (buildConditionsin SQL,filterVideoRowsfor the JSON fallback), instead of routing the tournament name through the genericqfree-text filter.Fullscreen covers the player and its metadata column (the whole left stage), not the video alone.Fixed:handleToggleFullscreennow callsrequestFullscreen()onvideoWrapperRef— the same element the picture-in-picture handoff already uses — instead of the ref on the whole left-column stage, so fullscreen shows only the video.The position handed back to the floating player comes from YouTube'sFixed:infoDeliverybroadcasts, so a video closed in the first second before any broadcast arrives resumes from 0.currentTimeRef— the same ref the popout handoff and the picture-in-picture toggle both read from — now seeds itself from the video's resolved saved position when the page claims playback, instead of a hard0. Popping into picture-in-picture reloads the embed fromresumeSeconds(moving the iframe into the PiP window resets its navigation state), andresumeSecondsis read straight fromcurrentTimeRefat the moment of the toggle — so toggling before the firstinfoDeliverybroadcast arrives now reopens at the video's actual second rather than0.
