From 7a41bbf8c116c812b5c165e98a67c569d1779a2e Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Fri, 12 Jun 2026 00:39:02 +0200 Subject: [PATCH] feat: DJ can be contributor, volume, theme switch, clock sync, 90s blindtest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../modes/blindtest/blindtest-round.test.ts | 18 +++++++- .../game/modes/blindtest/blindtest-round.ts | 15 ++++--- apps/server/src/handlers.ts | 3 ++ apps/web/src/components/blindtest-view.tsx | 42 ++++++++++++++++- apps/web/src/components/countdown.tsx | 8 ++-- apps/web/src/components/theme-toggle.tsx | 31 +++++++++++++ apps/web/src/i18n/en.ts | 1 + apps/web/src/i18n/fr.ts | 1 + apps/web/src/lib/clock.ts | 45 +++++++++++++++++++ apps/web/src/lib/youtube.ts | 3 ++ apps/web/src/pages/home.tsx | 6 ++- apps/web/src/pages/room.tsx | 2 + apps/web/src/store/room.ts | 1 + apps/web/src/types/youtube.d.ts | 1 + packages/shared/src/events.ts | 1 + 15 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/theme-toggle.tsx create mode 100644 apps/web/src/lib/clock.ts diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts index cdeb1ee..750e0d1 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -44,7 +44,7 @@ describe("match", () => { }) describe("BlindtestRound", () => { - test("start : le DJ est neutre (jamais le contributeur)", () => { + test("start : le DJ est un humain connecté (le contributeur peut tomber DJ)", () => { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") const { player: b } = rooms.join(room.code, "Bob", "sb") @@ -53,10 +53,24 @@ describe("BlindtestRound", () => { const round = new BlindtestRound() const start = round.start(room) - expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice) + // N'importe quel humain (Alice incluse), jamais un bot ni null. + expect([a.id, b.id]).toContain(start.djId!) expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" }) }) + test("start : un bot n'est jamais DJ", () => { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + rooms.addBot(room) + rooms.addBot(room) + room.blindtestTracks = [track(a.id)] + prepareBlindtestForRoom(room) + + const round = new BlindtestRound() + // Alice est la seule humaine → DJ forcément Alice (les bots sont exclus). + expect(round.start(room).djId).toBe(a.id) + }) + test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => { const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sa") diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index 53f86c9..0b0d46f 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -32,10 +32,14 @@ interface BlindtestRoundData { track: BlindtestTrack } -/** Tire un DJ neutre : un humain connecté qui n'a pas soumis ce titre. */ -function pickDj(room: ServerRoom, submittedBy: string): string | null { +// Le blindtest dure plus longtemps (écoute + saisie). +const BLINDTEST_DURATION_SEC = 90 + +/** Tire un DJ : n'importe quel humain connecté (y compris le contributeur, pour + * ne pas donner d'« immunité » à celui qui n'est jamais tiré). */ +function pickDj(room: ServerRoom): string | null { const eligible = [...room.players.values()].filter( - (p) => p.connected && !p.isBot && p.id !== submittedBy + (p) => p.connected && !p.isBot ) if (eligible.length === 0) { return null @@ -77,13 +81,14 @@ export class BlindtestRound implements GameRound { start(room: ServerRoom): RoundStart { const track = takeTrack(room) - // DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle. - const djId = track ? pickDj(room, track.submittedBy) : null + // DJ = n'importe quel humain (le contributeur peut tomber DJ). + const djId = track ? pickDj(room) : null const payload: BlindtestRoundPayload | null = track ? { trackId: track.id, youtubeId: track.youtubeId } : null return { djId, + durationSec: BLINDTEST_DURATION_SEC, payload, secretPlayerId: track?.submittedBy, data: track ? { track } : null, diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index d39772f..5749264 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -61,6 +61,9 @@ export function registerRoomHandlers( }) } + // Synchro d'horloge : le client mesure l'offset avec l'horloge serveur. + socket.on("time:sync", (ack) => ack(Date.now())) + socket.on("room:create", (payload, ack) => { // Le pseudo est choisi dans la room (player:setName) : on crée sans nom. const name = cleanName(payload?.playerName) ?? "" diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index 2b1dac3..026eb76 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -15,6 +15,7 @@ import { useYoutube, type YoutubeApi } from "@/lib/youtube" import { Countdown } from "@/components/countdown" import { Avatar } from "@/components/avatar" import { useI18n } from "@/i18n/context" +import { serverNow } from "@/lib/clock" const inputClass = "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" @@ -46,12 +47,31 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { // doit "armer" le son une fois (le DJ a déjà cliqué Play, donc dispensé). const [audioArmed, setAudioArmed] = useState(false) + // Volume local (par joueur), mémorisé entre les manches/parties. + const [volume, setVolume] = useState(() => { + const v = Number(localStorage.getItem("nerdware-volume")) + return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 100 + }) + useEffect(() => { + if (ready) { + api.setVolume(volume) + } + }, [ready, volume, api]) + function changeVolume(v: number) { + setVolume(v) + try { + localStorage.setItem("nerdware-volume", String(v)) + } catch { + // ignore + } + } + // Non-DJ : on suit le DJ via media:sync (avec compensation de latence). useEffect(() => { if (isDj || !ready || !mediaSync) { return } - const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 + const elapsed = (serverNow() - mediaSync.atServerTs) / 1000 if (mediaSync.action === "play") { api.seek(mediaSync.positionSec + Math.max(0, elapsed)) if (audioArmed) { @@ -68,7 +88,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { function armAudio() { setAudioArmed(true) if (mediaSync && mediaSync.action !== "pause") { - const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 + const elapsed = (serverNow() - mediaSync.atServerTs) / 1000 api.seek(mediaSync.positionSec + Math.max(0, elapsed)) } api.play() @@ -125,6 +145,24 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {!showReveal && ( +
+ + changeVolume(Number(e.target.value))} + className="accent-primary flex-1" + aria-label={t.blindtest.volume} + /> + + {volume} + +
+ )} + {isDj && !showReveal && ( )} diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index e50e4b1..cc61b08 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -1,15 +1,17 @@ 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. Avant `startsAt` (phase de préparation / transition), - * on affiche la durée pleine sans décompter. + * 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 = Date.now() + const now = serverNow() if (now < startsAt) { return Math.ceil((endsAt - startsAt) / 1000) } diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx new file mode 100644 index 0000000..57d0841 --- /dev/null +++ b/apps/web/src/components/theme-toggle.tsx @@ -0,0 +1,31 @@ +import { Monitor, Moon, Sun } from "lucide-react" +import { useTheme } from "@/components/theme-provider" + +const OPTIONS = [ + { value: "light", Icon: Sun }, + { value: "dark", Icon: Moon }, + { value: "system", Icon: Monitor }, +] as const + +/** Sélecteur de thème : clair / sombre / système. */ +export function ThemeToggle({ className = "" }: { className?: string }) { + const { theme, setTheme } = useTheme() + return ( +
+ {OPTIONS.map(({ value, Icon }) => ( + + ))} +
+ ) +} diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index f0da3a4..8129c18 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -110,6 +110,7 @@ export const en: Dict = { }, blindtest: { enableSound: "Enable sound", + volume: "Volume", djHint: "You're the DJ 🎧 — control playback (and vote like everyone)", play: "Play", pause: "Pause", diff --git a/apps/web/src/i18n/fr.ts b/apps/web/src/i18n/fr.ts index 6b85d83..c6edb91 100644 --- a/apps/web/src/i18n/fr.ts +++ b/apps/web/src/i18n/fr.ts @@ -108,6 +108,7 @@ export const fr = { }, blindtest: { enableSound: "Activer le son", + volume: "Volume", djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)", play: "Play", pause: "Pause", diff --git a/apps/web/src/lib/clock.ts b/apps/web/src/lib/clock.ts new file mode 100644 index 0000000..2415fd2 --- /dev/null +++ b/apps/web/src/lib/clock.ts @@ -0,0 +1,45 @@ +// Synchro d'horloge avec le serveur : on estime l'offset (horloge serveur − +// horloge locale) pour que tous les clients affichent le même décompte, quel que +// soit le décalage d'horloge de chaque machine. + +import { socket } from "./socket" + +let offset = 0 + +/** Instant courant aligné sur l'horloge serveur. */ +export function serverNow(): number { + return Date.now() + offset +} + +/** Une mesure : renvoie [rtt, offset estimé]. */ +async function syncOnce(): Promise<[number, number]> { + const t0 = Date.now() + const serverTime = await new Promise((resolve) => + socket.emit("time:sync", resolve) + ) + const t1 = Date.now() + const rtt = t1 - t0 + // Au moment de la réponse (t1), l'horloge serveur valait ~ serverTime + rtt/2. + return [rtt, serverTime + rtt / 2 - t1] +} + +/** Plusieurs mesures : on garde celle de plus faible latence (plus fiable). */ +async function sync(): Promise { + let bestRtt = Infinity + for (let i = 0; i < 3; i++) { + try { + const [rtt, est] = await syncOnce() + if (rtt < bestRtt) { + bestRtt = rtt + offset = est + } + } catch { + return + } + } +} + +socket.on("connect", () => void sync()) +if (socket.connected) { + void sync() +} diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts index 945fd53..d299db1 100644 --- a/apps/web/src/lib/youtube.ts +++ b/apps/web/src/lib/youtube.ts @@ -29,6 +29,8 @@ export interface YoutubeApi { seek: (seconds: number) => void time: () => number duration: () => number + /** Volume 0–100. */ + setVolume: (v: number) => void } /** @@ -95,6 +97,7 @@ export function useYoutube(youtubeId: string) { seek: (seconds) => playerRef.current?.seekTo(seconds, true), time: () => playerRef.current?.getCurrentTime() ?? 0, duration: () => playerRef.current?.getDuration() ?? 0, + setVolume: (v) => playerRef.current?.setVolume(v), }), [] ) diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index 41b6e70..a862dd9 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -7,6 +7,7 @@ import { useRoomStore } from "@/store/room" import { useI18n } from "@/i18n/context" import { errorMessage } from "@/i18n/error" import { LanguageToggle } from "@/i18n/language-toggle" +import { ThemeToggle } from "@/components/theme-toggle" const inputClass = "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" @@ -60,7 +61,10 @@ export function HomePage() { return (
- +
+ + +
{/* Halo animé */} +