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 it

PersistentVideoPlayer 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;
  • theaterVideoId is 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.ts is pure. buildSpeechOutcomePrompt sends 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. parseSpeechOutcomeResponse accepts 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 contradicting winner (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; normalizePanel dedupes, orders canonically and caps at five). The model returns ballots — one per judge, with rfd and decisive — for the speech as given and each alternative. aggregatePanel derives the option's PredictedBallot from them rather than asking the model: the winner is the vote majority (an even split goes to the side majorityProbability favours), affWinProbability is the chance a majority votes aff given each judge's own odds, and rfd is the tally with the dissenters. A panel of one is that judge's ballot. A reply with a single legacy ballot still parses.
  • lib/speech-outcomes-client.ts posts to the shared /api/reason-ai proxy. It does not add a route, and the proxy's sign-in gate applies. outcomeMaxTokens scales the reply budget with the panel size.
  • state/speechOutcomeCache.ts keeps 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) in localStorage, 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: CachedSpeechOutcome now carries a derived ${videoId}::${speechKey}::${lens} id (speechOutcomeCacheId) and is registered in debate-data-sync's TOOL_RECORD_COLLECTIONS as speechOutcomeRuns (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 a LIKE search 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. Fixed: getRelatedVideos' tournament pass now filters on VideoQueryParams.tournament, a dedicated predicate scoped to the tournament column/field alone (buildConditions in SQL, filterVideoRows for the JSON fallback), instead of routing the tournament name through the generic q free-text filter.
  • Fullscreen covers the player and its metadata column (the whole left stage), not the video alone. Fixed: handleToggleFullscreen now calls requestFullscreen() on videoWrapperRef — 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's infoDelivery broadcasts, so a video closed in the first second before any broadcast arrives resumes from 0. Fixed: 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 hard 0. Popping into picture-in-picture reloads the embed from resumeSeconds (moving the iframe into the PiP window resets its navigation state), and resumeSeconds is read straight from currentTimeRef at the moment of the toggle — so toggling before the first infoDelivery broadcast arrives now reopens at the video's actual second rather than 0.