Phase 13: questions, sounds and videos on the scoreboard

A game can now carry a list of items the host puts on the scoreboard one at
a time: a question (shown above the standings, with an optional picture and
an answer to reveal), a sound (played through the scoreboard's speakers) or
a video such as a karaoke track (takes over the screen).

- Media is a YouTube link, an https link to a file, or an upload to the
  private game-media bucket. The scoreboard API signs a URL only for the
  item that is on screen.
- What is on screen is state on the event, like the timer, so the ping
  still carries no data. An unrevealed answer and a sound's or video's name
  never reach the scoreboard payload.
- Control room: click an item to show it, or "Random" to have one picked
  (prefers items not played yet). Show/hide answer, pause, take off screen;
  shortcuts Q / A / X.
- Duplicating a game or event copies its items and files; deleting removes
  the files.

Needs supabase/migrations/0006_screen_items.sql applied before deploying.
database.types.ts was edited by hand to match; re-run gen:types after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:54:08 +02:00
co-authored by Claude Opus 5.5
parent c27a3890f8
commit 825eb14f2e
30 changed files with 1967 additions and 60 deletions
+34 -1
View File
@@ -24,7 +24,8 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middleware) protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`. Every server action re-checks via `requireAdmin()`.
## Supabase clients (`src/lib/supabase/`)
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session.
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session (which is also what
uploads an admin's media file straight to storage).
- `server.ts` — cookie-based, for admin server actions (RLS applies as the admin user).
- `admin.ts` — service role, **server-only** (`import 'server-only'`). Used by public/judge route handlers after token validation.
@@ -35,6 +36,9 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa
- Judge write checks: judge not revoked, game not locked/done, criterion belongs to judge's game, team belongs to the game's event, value within min/max and on step. (DB trigger also enforces range.)
- Scoreboard API checks `scoreboard_visibility` and strips scores when `hidden`; returns `frozen_snapshot` when `frozen`.
- Buzz checks: the token's team exists, its event's current game has `buzzer_enabled`, isn't done, and nobody has been marked correct for this question. The team and game come from the token alone — the request has no body. One row per (game, team), so a repeat press is a no-op.
- Uploaded media lives in the private `game-media` bucket under `{eventId}/…`. Storage policies
allow the event's admins only; anon has none. The scoreboard API hands out a signed URL (12 h)
for the one item that is on screen, and nothing else. Links (YouTube, files) must be `https://`.
- Light rate limiting on judge PUT (20 req/s per token), buzz POST (5 req/s per token) and open-link join (3 req/s per link), in-memory per server instance. An open link can create at most 50 judges per game.
## Realtime (Broadcast "ping then refetch")
@@ -81,9 +85,18 @@ type ScoreboardPayload = {
}>;
answeringTeamId: string | null; // earliest unmarked buzz; null once someone got it
} | null;
// null with nothing on screen; sent regardless of score visibility (see "On screen")
onScreen:
| { kind: 'question'; id: string; shownAt: string; text: string;
imageUrl: string | null; answer: string | null } // answer only once revealed
| { kind: 'sound'; id: string; shownAt: string; media: ScreenMedia; paused: boolean;
answer: string | null }
| { kind: 'video'; id: string; shownAt: string; media: ScreenMedia; paused: boolean }
| null;
updatedAt: string; // server clock; screens use it to correct their own
};
```
`ScreenMedia` = `{ type: 'youtube'; videoId; startSeconds }` or `{ type: 'file'; url }`.
During reveal mode, teams not yet revealed are sent without rank/total, alphabetically and before the revealed ones, so their order leaks nothing.
When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live. A missing or invalid snapshot is treated as hidden, never as live.
@@ -113,6 +126,26 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live
and step. The score is written before the mark, so a refused point leaves the answer unmarked.
- A buzz is refused once any buzz of the game is marked correct, until the host clears.
## On screen (questions, sounds, videos)
- A game's items are rows in `game_items`. What is on screen is state on `events`
(`screen_item_id`, `screen_shown_at`, `screen_answer_shown`, `screen_paused`), like the timer.
An item only counts while its game is the current game; switching game clears it.
- `src/lib/screen.ts` holds the rules as pure functions: `buildOnScreen` (the sanitised payload
part — no unrevealed answer, no sound/video name), `parseYouTube`, `pickRandom`,
`nextQuestion`, and the upload checks. Server actions in `live-actions.ts` (show, random,
answer, pause, clear) and `item-actions.ts` (setup CRUD).
- Rule 4 still holds: the ping carries nothing; the scoreboard refetches and plays what the
payload says. Each player is mounted with a key of item id + `shownAt`, so showing an item
again restarts it, and it keeps the media URL it started with (a signed URL differs in every
payload; following it would restart the file on every refetch).
- Playback: files use `<video>` / `<audio>`; YouTube uses the `youtube-nocookie.com` embed, with
mute and pause sent as `postMessage` player commands. The control room decides play/pause and
restart only — there is no shared playback position, so a scoreboard opened mid-song starts
the song from the beginning.
- Uploads go from the admin's browser to storage (a server action can't carry 50 MB); the
action then records the path, after checking it is inside that event's folder. Deleting an
item, game or event deletes its files; duplicating copies them.
## Folder layout
```
src/