Files
game-scoreboard/src/lib/scoreboard-sounds.ts
T
joshuaandClaude Opus 5.5 bf5b6194f6 Add scoreboard countdown timer and sound effects
- Timer: one per event, set from the control room (duration, presets,
  start/pause/resume/reset, show/hide, T shortcut). Rules are pure
  functions in src/lib/timer.ts; the server's clock sets the end time and
  each screen counts down locally, corrected for its own clock.
  Needs supabase/migrations/0002_timer.sql.
- Sound effects on the scoreboard, synthesised with Web Audio (no files):
  drum-roll + crash per revealed team, fanfare for first place, a chime
  on game change, ticks for the last 10 seconds and a buzzer at zero.
  Triggers are pure functions comparing consecutive payloads.
- 'Turn sound on' button on the scoreboard, since browsers block audio
  until a click; the choice is remembered across reloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:14:10 +02:00

54 lines
2.1 KiB
TypeScript

import type { ScoreboardPayload } from "@/lib/scoreboard";
import type { SoundName } from "@/lib/sound";
import type { TimerStatus } from "@/lib/timer";
// Which sound effects a change on the scoreboard should trigger. Pure, so the rules
// are testable without a browser; the scoreboard component just plays what this returns.
const revealedIds = (payload: ScoreboardPayload) =>
new Set(
payload.reveal?.active ? payload.teams.filter((team) => team.rank !== undefined).map((team) => team.id) : [],
);
/** Sounds for going from one payload to the next. */
export function soundsForUpdate(
before: ScoreboardPayload,
after: ScoreboardPayload,
options: { reducedMotion: boolean },
): SoundName[] {
const sounds: SoundName[] = [];
// A new current game (including the first one of the night).
if (after.currentGame && after.currentGame.id !== before.currentGame?.id) sounds.push("game");
if (after.reveal?.active) {
const already = revealedIds(before);
const newlyRevealed = [...revealedIds(after)].some((id) => !already.has(id));
if (newlyRevealed) {
// The last team to appear is first place: it gets the fanfare.
const isWinner = after.reveal.revealedCount >= after.teams.length;
// With reduced motion the row appears at once, so a drum-roll would trail behind it.
if (options.reducedMotion) sounds.push(isWinner ? "revealWinner" : "crash");
else sounds.push(isWinner ? "revealWinner" : "reveal");
}
}
return sounds;
}
/**
* Sound for the countdown reaching a new whole second. `previousSeconds` is what the clock
* showed a moment ago; null on the first look, so loading the page mid-countdown or after
* time is up stays silent.
*/
export function soundForCountdown(
status: TimerStatus,
previousSeconds: number | null,
seconds: number,
): SoundName | null {
if (previousSeconds === null || seconds >= previousSeconds) return null;
if (status !== "running" && status !== "finished") return null;
if (seconds === 0) return "buzzer";
if (status !== "running" || seconds > 10) return null;
return seconds <= 3 ? "tickFinal" : "tick";
}