From cfc5339a2518ebec772c114549a5e39444b764cc Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 23:54:56 +0200 Subject: [PATCH 1/4] fix(server): connect via PG* vars (robust to special chars in password) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit postgres-js failed to parse DATABASE_URL when the password contained URL-special characters (@ : / # ...). New createSqlClient(): uses DATABASE_URL if set, else falls back to PG* env vars (PGHOST/PGUSER/PGPASSWORD/...) which postgres-js reads directly — password passed verbatim, no URL encoding. docker-compose.prod.yml now sets PG* for the server instead of an inline DSN. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/db/client.ts | 21 +++++++++++++++++++++ apps/server/src/db/index.ts | 7 +++---- apps/server/src/db/migrate.ts | 10 ++++------ apps/server/src/db/seed-images.ts | 14 ++++++-------- apps/server/src/db/seed.ts | 10 ++++------ docker-compose.prod.yml | 7 ++++++- 6 files changed, 44 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/db/client.ts diff --git a/apps/server/src/db/client.ts b/apps/server/src/db/client.ts new file mode 100644 index 0000000..16a0039 --- /dev/null +++ b/apps/server/src/db/client.ts @@ -0,0 +1,21 @@ +// Création du client postgres-js. Deux modes : +// 1. DATABASE_URL (DSN complet) — pratique en dev. +// 2. Variables PG* (PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE) — recommandé en +// prod : le mot de passe est passé tel quel (pas d'encodage d'URL à gérer). +// postgres-js lit automatiquement les PG* de l'environnement quand on ne lui +// passe pas de DSN. + +import postgres from "postgres" +import { env } from "../env" + +export const hasDbConfig = !!env.databaseUrl || !!process.env.PGHOST + +export function createSqlClient(opts: { max?: number } = {}) { + if (env.databaseUrl) { + return postgres(env.databaseUrl, opts) + } + if (process.env.PGHOST) { + return postgres(opts) + } + return null +} diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts index a6712e1..f0e5d6c 100644 --- a/apps/server/src/db/index.ts +++ b/apps/server/src/db/index.ts @@ -2,17 +2,16 @@ // le jeu retombe sur la banque de questions en dur (dev sans infra). import { drizzle } from "drizzle-orm/postgres-js" -import postgres from "postgres" -import { env } from "../env" +import { createSqlClient } from "./client" import * as schema from "./schema" export type Database = ReturnType> function createDb(): Database | null { - if (!env.databaseUrl) { + const client = createSqlClient() + if (!client) { return null } - const client = postgres(env.databaseUrl) return drizzle(client, { schema }) } diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts index 6f708c4..f552292 100644 --- a/apps/server/src/db/migrate.ts +++ b/apps/server/src/db/migrate.ts @@ -2,15 +2,13 @@ // bun run db:migrate import { drizzle } from "drizzle-orm/postgres-js" import { migrate } from "drizzle-orm/postgres-js/migrator" -import postgres from "postgres" -import { env } from "../env" +import { createSqlClient } from "./client" -if (!env.databaseUrl) { - console.error("DATABASE_URL manquant — rien à migrer.") +const client = createSqlClient({ max: 1 }) +if (!client) { + console.error("Aucune config DB (DATABASE_URL ou PG*) — rien à migrer.") process.exit(1) } - -const client = postgres(env.databaseUrl, { max: 1 }) const db = drizzle(client) await migrate(db, { migrationsFolder: "./drizzle" }) diff --git a/apps/server/src/db/seed-images.ts b/apps/server/src/db/seed-images.ts index 7c642b4..69e31aa 100644 --- a/apps/server/src/db/seed-images.ts +++ b/apps/server/src/db/seed-images.ts @@ -6,21 +6,19 @@ import { eq } from "drizzle-orm" import { drizzle } from "drizzle-orm/postgres-js" -import postgres from "postgres" -import { env } from "../env" +import { createSqlClient } from "./client" import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema" -if (!env.databaseUrl) { - console.error("DATABASE_URL manquant — impossible de seeder.") - process.exit(1) -} - const COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151) const CATEGORY = "Pokémon" const PROMPT = "Quel est ce Pokémon ?" const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) -const client = postgres(env.databaseUrl, { max: 1 }) +const client = createSqlClient({ max: 1 }) +if (!client) { + console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.") + process.exit(1) +} const db = drizzle(client, { schema: { quizCategory, quizQuestion } }) async function upsertCategory(name: string): Promise { diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts index 6af9d3c..978b89d 100644 --- a/apps/server/src/db/seed.ts +++ b/apps/server/src/db/seed.ts @@ -5,8 +5,7 @@ import { eq } from "drizzle-orm" import { drizzle } from "drizzle-orm/postgres-js" -import postgres from "postgres" -import { env } from "../env" +import { createSqlClient } from "./client" import { quizCategory, quizQuestion, @@ -14,12 +13,11 @@ import { } from "./schema" import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions" -if (!env.databaseUrl) { - console.error("DATABASE_URL manquant — impossible de seeder.") +const client = createSqlClient({ max: 1 }) +if (!client) { + console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.") process.exit(1) } - -const client = postgres(env.databaseUrl, { max: 1 }) const db = drizzle(client, { schema: { quizCategory, quizQuestion } }) // Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d7d3a9c..32ed268 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -34,7 +34,12 @@ services: NODE_ENV: production HOST: 0.0.0.0 PORT: 3001 - DATABASE_URL: postgres://${POSTGRES_USER:-nerdware}:${POSTGRES_PASSWORD:-nerdware}@postgres:5432/${POSTGRES_DB:-nerdware} + # Connexion via PG* (mot de passe passé tel quel, pas d'encodage d'URL). + PGHOST: postgres + PGPORT: 5432 + PGUSER: ${POSTGRES_USER:-nerdware} + PGPASSWORD: ${POSTGRES_PASSWORD:-nerdware} + PGDATABASE: ${POSTGRES_DB:-nerdware} ADMIN_TOKEN: ${ADMIN_TOKEN} CORS_ORIGINS: ${CORS_ORIGINS} UPLOADS_DIR: /data/uploads From 6362b9750855fc03e8c2093c6433c5df6963901e Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Fri, 12 Jun 2026 00:16:27 +0200 Subject: [PATCH 2/4] fix(web): blindtest sound, hidden vote status, authoritative tracks, quiz input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From friends playtest feedback: - blindtest "who added it?" (and mixed): hide the per-player vote status — the contributor never validates, which gave them away - blindtest sound: set YT player `origin`/`enablejsapi` (fixes the postMessage origin warning) and add a per-game "Activer le son" gesture for non-DJ (browsers block audible autoplay without a user gesture) - track list: render each player's own tracks from a server-sent, authoritative blindtest:myTracks event (emitted on submit/remove/reconnect) instead of local state that desynced on remount — fixes the "3/3 but only 2 shown" case - quiz: reset the free-answer input between questions (was pre-filled with the previous answer) Verified e2e: myTracks emitted on submit + reconnect. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/handlers.ts | 12 +++++++++ apps/web/src/components/blindtest-view.tsx | 31 +++++++++++++++++++--- apps/web/src/components/lobby-view.tsx | 25 +++++------------ apps/web/src/components/quiz-view.tsx | 7 ++++- apps/web/src/i18n/en.ts | 1 + apps/web/src/i18n/fr.ts | 1 + apps/web/src/lib/youtube.ts | 4 +++ apps/web/src/pages/room.tsx | 10 ++++++- apps/web/src/store/room.ts | 8 ++++++ packages/shared/src/events.ts | 12 +++++++++ 10 files changed, 88 insertions(+), 23 deletions(-) diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index da2fe57..d39772f 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -52,6 +52,15 @@ export function registerRoomHandlers( io.to(room.code).emit("room:state", rooms.toSnapshot(room)) } + // Renvoie au joueur SES propres titres (source de vérité, anti-désync client). + const emitMyTracks = (room: ServerRoom, playerId: string) => { + socket.emit("blindtest:myTracks", { + tracks: room.blindtestTracks + .filter((t) => t.submittedBy === playerId) + .map((t) => ({ trackId: t.id, title: t.title, youtubeId: t.youtubeId })), + }) + } + 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) ?? "" @@ -111,6 +120,7 @@ export function registerRoomHandlers( }) io.to(room.code).emit("player:rejoined", { playerId: player.id }) broadcastState(room) + emitMyTracks(room, player.id) // Re-pousse la manche en cours au socket reconnecté (si partie en cours). room.game?.resync(socket.id, player.id) }) @@ -293,6 +303,7 @@ export function registerRoomHandlers( title: meta.title, count: mine.length + 1, }) + emitMyTracks(room, playerId) broadcastState(room) }) @@ -311,6 +322,7 @@ export function registerRoomHandlers( const removed = room.blindtestTracks.length < before ack({ ok: removed }) if (removed) { + emitMyTracks(room, playerId) broadcastState(room) } }) diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index af3eacf..2b1dac3 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react" import { motion } from "framer-motion" -import { Disc3, Pause, Play, Rewind } from "lucide-react" +import { Disc3, Pause, Play, Rewind, Volume2 } from "lucide-react" import type { BlindtestAnswer, BlindtestMode, @@ -42,6 +42,10 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null const showReveal = truth !== null + // Les navigateurs bloquent la lecture audible sans geste utilisateur : le non-DJ + // doit "armer" le son une fois (le DJ a déjà cliqué Play, donc dispensé). + const [audioArmed, setAudioArmed] = useState(false) + // Non-DJ : on suit le DJ via media:sync (avec compensation de latence). useEffect(() => { if (isDj || !ready || !mediaSync) { @@ -50,14 +54,25 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 if (mediaSync.action === "play") { api.seek(mediaSync.positionSec + Math.max(0, elapsed)) - api.play() + if (audioArmed) { + api.play() + } } else if (mediaSync.action === "pause") { api.seek(mediaSync.positionSec) api.pause() } else { api.seek(mediaSync.positionSec) } - }, [mediaSync, isDj, ready, api]) + }, [mediaSync, isDj, ready, api, audioArmed]) + + function armAudio() { + setAudioArmed(true) + if (mediaSync && mediaSync.action !== "pause") { + const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 + api.seek(mediaSync.positionSec + Math.max(0, elapsed)) + } + api.play() + } // Au reveal, on coupe le son. useEffect(() => { @@ -98,6 +113,16 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {/* Non-DJ : geste requis pour débloquer l'audio (autoplay policy). */} + {!showReveal && !isDj && !audioArmed && ( + + )} {isDj && !showReveal && ( diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index fc6a6c6..d7b83d6 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -550,12 +550,6 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { ) } -interface MyTrack { - trackId: string - title: string - youtubeId: string -} - function TrackSubmission({ tracksPerPlayer, myCount, @@ -565,11 +559,12 @@ function TrackSubmission({ }) { const submitTrack = useRoomStore((s) => s.submitTrack) const removeTrack = useRoomStore((s) => s.removeTrack) + // Source de vérité : le serveur renvoie nos titres (anti-désync au remount). + const myTracks = useRoomStore((s) => s.myTracks) const { t } = useI18n() const [url, setUrl] = useState("") const [submitting, setSubmitting] = useState(false) const [feedback, setFeedback] = useState(null) - const [tracks, setTracks] = useState([]) const quotaReached = myCount >= tracksPerPlayer async function submit() { @@ -579,11 +574,8 @@ function TrackSubmission({ setSubmitting(true) setFeedback(null) const res = await submitTrack(url.trim()) - if (res.accepted && res.trackId && res.youtubeId) { - setTracks((t) => [ - ...t, - { trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! }, - ]) + if (res.accepted) { + // La liste se met à jour via l'event serveur blindtest:myTracks. setUrl("") } else { setFeedback(res.reason ?? t.lobby.rejected) @@ -592,10 +584,7 @@ function TrackSubmission({ } async function remove(trackId: string) { - const ok = await removeTrack(trackId) - if (ok) { - setTracks((t) => t.filter((x) => x.trackId !== trackId)) - } + await removeTrack(trackId) } return ( @@ -604,9 +593,9 @@ function TrackSubmission({ {t.lobby.myTracks(myCount, tracksPerPlayer)} - {tracks.length > 0 && ( + {myTracks.length > 0 && (
    - {tracks.map((track) => ( + {myTracks.map((track) => (
  • + ) : (
    {(question.choices ?? []).map((choice, index) => ( diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index 785fbaa..f0da3a4 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -109,6 +109,7 @@ export const en: Dict = { validate: "Submit", }, blindtest: { + enableSound: "Enable sound", 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 ce0220f..6b85d83 100644 --- a/apps/web/src/i18n/fr.ts +++ b/apps/web/src/i18n/fr.ts @@ -107,6 +107,7 @@ export const fr = { validate: "Valider", }, blindtest: { + enableSound: "Activer le son", djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)", play: "Play", pause: "Pause", diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts index 13f47a0..945fd53 100644 --- a/apps/web/src/lib/youtube.ts +++ b/apps/web/src/lib/youtube.ts @@ -65,6 +65,10 @@ export function useYoutube(youtubeId: string) { modestbranding: 1, rel: 0, playsinline: 1, + // Évite l'avertissement postMessage "target origin does not match" + // et fiabilise la transmission des commandes au lecteur. + enablejsapi: 1, + origin: window.location.origin, }, events: { onReady: () => { diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 6e88854..ecdab75 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -129,7 +129,15 @@ export function RoomPage({ code }: { code: string }) { {round?.type === "blindtest" ? ( diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 241afc0..fb76f6e 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -6,6 +6,7 @@ import { type ErrorPayload, type MediaControlPayload, type MediaSyncPayload, + type MyTrackInfo, type PlayerScore, type RoomCreatedPayload, type RoomSnapshot, @@ -51,6 +52,8 @@ interface RoomState { pseudoSet: boolean snapshot: RoomSnapshot | null lastError: ErrorPayload | null + /** Mes titres blindtest (renvoyés par le serveur, source de vérité). */ + myTracks: MyTrackInfo[] // État de jeu (poussé par le moteur serveur). round: ActiveRound | null @@ -96,6 +99,7 @@ export const useRoomStore = create((set, get) => ({ pseudoSet: !!saved?.name, snapshot: null, lastError: null, + myTracks: [], round: null, voteProgress: null, reveal: null, @@ -242,6 +246,7 @@ export const useRoomStore = create((set, get) => ({ pseudoSet: false, snapshot: null, lastError: null, + myTracks: [], round: null, voteProgress: null, reveal: null, @@ -303,6 +308,9 @@ socket.on("room:state", (snapshot) => ) ) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) +socket.on("blindtest:myTracks", ({ tracks }) => + useRoomStore.setState({ myTracks: tracks }) +) socket.on("round:start", (payload) => useRoomStore.setState((s) => { diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index fb5090b..cd2daf6 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -78,6 +78,17 @@ export interface RemoveTrackPayload { trackId: string } +/** Un titre soumis par le joueur lui-même (renvoyé en privé, source de vérité). */ +export interface MyTrackInfo { + trackId: string + title: string + youtubeId: string +} + +export interface MyTracksPayload { + tracks: MyTrackInfo[] +} + export interface RoundStartPayload { type: RoundType djId?: string @@ -175,6 +186,7 @@ export interface ServerToClientEvents { "player:left": (payload: PlayerRefPayload) => void "player:rejoined": (payload: PlayerRefPayload) => void "blindtest:submitOk": (payload: SubmitOkPayload) => void + "blindtest:myTracks": (payload: MyTracksPayload) => void "round:start": (payload: RoundStartPayload) => void "media:sync": (payload: MediaSyncPayload) => void "round:voteAck": (payload: VoteAckPayload) => void From 7a41bbf8c116c812b5c165e98a67c569d1779a2e Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Fri, 12 Jun 2026 00:39:02 +0200 Subject: [PATCH 3/4] 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é */} +