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/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 da2fe57..5749264 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -52,6 +52,18 @@ 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 })), + }) + } + + // 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) ?? "" @@ -111,6 +123,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 +306,7 @@ export function registerRoomHandlers( title: meta.title, count: mine.length + 1, }) + emitMyTracks(room, playerId) broadcastState(room) }) @@ -311,6 +325,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..026eb76 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, @@ -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" @@ -42,22 +43,56 @@ 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) + + // 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)) - 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 = (serverNow() - mediaSync.atServerTs) / 1000 + api.seek(mediaSync.positionSec + Math.max(0, elapsed)) + } + api.play() + } // Au reveal, on coupe le son. useEffect(() => { @@ -98,8 +133,36 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {/* Non-DJ : geste requis pour débloquer l'audio (autoplay policy). */} + {!showReveal && !isDj && !audioArmed && ( + + )} + {!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/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/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 785fbaa..8129c18 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -109,6 +109,8 @@ export const en: Dict = { validate: "Submit", }, 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 ce0220f..c6edb91 100644 --- a/apps/web/src/i18n/fr.ts +++ b/apps/web/src/i18n/fr.ts @@ -107,6 +107,8 @@ export const fr = { validate: "Valider", }, 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 13f47a0..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 } /** @@ -65,6 +67,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: () => { @@ -91,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é */} +