- 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>
54 lines
2.1 KiB
TypeScript
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";
|
|
}
|