nerdware/apps/web/src/components/countdown.tsx
AyoubBenziza 7a41bbf8c1 feat: DJ can be contributor, volume, theme switch, clock sync, 90s blindtest
Playtest feedback round 2:
- blindtest DJ can now be anyone incl. the track's contributor (pickDj only
  excludes bots) — no more "immunity" for whoever isn't picked
- blindtest rounds last 90s (longer listen + typing)
- per-player volume slider (persisted), YT player setVolume + origin/enablejsapi
- theme switcher (light/dark/system) on home + room header
- clock sync: clients estimate the server-clock offset (time:sync) so the
  countdown is identical across devices (was off by each device's clock skew);
  also used for blindtest media sync

Note on duplicates: anti-repeat is per room code (quiz_played) — it works when
you replay in the SAME room, but a new room starts fresh. Verified e2e: two
chained games in one room share 0 questions.

Verified: typecheck 4/4, lint 4/4, 24 server tests, web build; e2e dedup (0
overlap) and clock/DJ behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:39:02 +02:00

89 lines
2.6 KiB
TypeScript

import { useEffect, useRef, useState } from "react"
import { motion } from "framer-motion"
import { useRoomStore } from "@/store/room"
import { serverNow } from "@/lib/clock"
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
/**
* Secondes restantes, basées sur l'horloge serveur (serverNow) pour que tous les
* clients affichent le même décompte. Avant `startsAt` (préparation), on affiche
* la durée pleine sans décompter.
*/
function secondsLeft(startsAt: number, endsAt: number): number {
const now = serverNow()
if (now < startsAt) {
return Math.ceil((endsAt - startsAt) / 1000)
}
return Math.max(0, Math.ceil((endsAt - now) / 1000))
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
/** Blanc → rouge sur les DANGER_FROM dernières secondes. */
function dangerColor(t: number): string {
const r = Math.round(lerp(255, 239, t))
const g = Math.round(lerp(255, 68, t))
const b = Math.round(lerp(255, 68, t))
return `rgb(${r}, ${g}, ${b})`
}
export function Countdown({
startsAt,
endsAt,
}: {
startsAt: number
endsAt: number
}) {
const [left, setLeft] = useState(() => secondsLeft(startsAt, endsAt))
const boom = useRoomStore((s) => s.boom)
const boomed = useRef(false)
useEffect(() => {
const id = setInterval(() => setLeft(secondsLeft(startsAt, endsAt)), 250)
return () => clearInterval(id)
}, [startsAt, endsAt])
// Déclenche l'explosion une seule fois au passage à 0 (fin au timer).
useEffect(() => {
if (left === 0 && !boomed.current) {
boomed.current = true
boom()
}
}, [left, boom])
const exploded = left === 0
const danger = left <= DANGER_FROM && left > 0
// 0 (début du danger) → 1 (explosion imminente)
const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0
// Plus on approche de 0, plus le "battement" est ample et rapide.
const peak = 1.15 + intensity * 0.5
const wiggle = 4 + intensity * 12
const duration = 0.7 - intensity * 0.46
return (
<motion.span
className="font-heading inline-flex items-center gap-1 text-2xl font-bold tabular-nums"
style={{ color: danger ? dangerColor(intensity) : undefined }}
animate={
danger
? {
scale: [1, peak, 0.92, peak * 0.97, 1],
rotate: [0, -wiggle, wiggle, -wiggle * 0.6, 0],
}
: { scale: 1, rotate: 0 }
}
transition={
danger
? { duration, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.2 }
}
>
{danger && <span aria-hidden>💣</span>}
{exploded ? "💥" : left}
</motion.span>
)
}