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>
89 lines
2.6 KiB
TypeScript
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>
|
|
)
|
|
}
|