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:
+34
-1
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user