release/0.1.1 #20

Merged
ayoub merged 6 commits from release/0.1.1 into main 2026-06-12 10:35:32 +00:00
23 changed files with 296 additions and 60 deletions
Showing only changes of commit 4497d697f7 - Show all commits

View file

@ -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
}

View file

@ -2,17 +2,16 @@
// le jeu retombe sur la banque de questions en dur (dev sans infra). // le jeu retombe sur la banque de questions en dur (dev sans infra).
import { drizzle } from "drizzle-orm/postgres-js" import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres" import { createSqlClient } from "./client"
import { env } from "../env"
import * as schema from "./schema" import * as schema from "./schema"
export type Database = ReturnType<typeof drizzle<typeof schema>> export type Database = ReturnType<typeof drizzle<typeof schema>>
function createDb(): Database | null { function createDb(): Database | null {
if (!env.databaseUrl) { const client = createSqlClient()
if (!client) {
return null return null
} }
const client = postgres(env.databaseUrl)
return drizzle(client, { schema }) return drizzle(client, { schema })
} }

View file

@ -2,15 +2,13 @@
// bun run db:migrate // bun run db:migrate
import { drizzle } from "drizzle-orm/postgres-js" import { drizzle } from "drizzle-orm/postgres-js"
import { migrate } from "drizzle-orm/postgres-js/migrator" import { migrate } from "drizzle-orm/postgres-js/migrator"
import postgres from "postgres" import { createSqlClient } from "./client"
import { env } from "../env"
if (!env.databaseUrl) { const client = createSqlClient({ max: 1 })
console.error("DATABASE_URL manquant — rien à migrer.") if (!client) {
console.error("Aucune config DB (DATABASE_URL ou PG*) — rien à migrer.")
process.exit(1) process.exit(1)
} }
const client = postgres(env.databaseUrl, { max: 1 })
const db = drizzle(client) const db = drizzle(client)
await migrate(db, { migrationsFolder: "./drizzle" }) await migrate(db, { migrationsFolder: "./drizzle" })

View file

@ -6,21 +6,19 @@
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/postgres-js" import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres" import { createSqlClient } from "./client"
import { env } from "../env"
import { quizCategory, quizQuestion, type NewQuizQuestion } from "./schema" 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 COUNT = Number(process.env.IMAGE_SEED_COUNT ?? 151)
const CATEGORY = "Pokémon" const CATEGORY = "Pokémon"
const PROMPT = "Quel est ce Pokémon ?" const PROMPT = "Quel est ce Pokémon ?"
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) 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 } }) const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
async function upsertCategory(name: string): Promise<string> { async function upsertCategory(name: string): Promise<string> {

View file

@ -5,8 +5,7 @@
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { drizzle } from "drizzle-orm/postgres-js" import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres" import { createSqlClient } from "./client"
import { env } from "../env"
import { import {
quizCategory, quizCategory,
quizQuestion, quizQuestion,
@ -14,12 +13,11 @@ import {
} from "./schema" } from "./schema"
import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions" import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions"
if (!env.databaseUrl) { const client = createSqlClient({ max: 1 })
console.error("DATABASE_URL manquant — impossible de seeder.") if (!client) {
console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.")
process.exit(1) process.exit(1)
} }
const client = postgres(env.databaseUrl, { max: 1 })
const db = drizzle(client, { schema: { quizCategory, quizQuestion } }) const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché). // Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché).

View file

@ -44,7 +44,7 @@ describe("match", () => {
}) })
describe("BlindtestRound", () => { 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 rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa") const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb") const { player: b } = rooms.join(room.code, "Bob", "sb")
@ -53,10 +53,24 @@ describe("BlindtestRound", () => {
const round = new BlindtestRound() const round = new BlindtestRound()
const start = round.start(room) 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" }) 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)", () => { test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
const rooms = new RoomManager() const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa") const { room, player: a } = rooms.create("Alice", "sa")

View file

@ -32,10 +32,14 @@ interface BlindtestRoundData {
track: BlindtestTrack track: BlindtestTrack
} }
/** Tire un DJ neutre : un humain connecté qui n'a pas soumis ce titre. */ // Le blindtest dure plus longtemps (écoute + saisie).
function pickDj(room: ServerRoom, submittedBy: string): string | null { 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( const eligible = [...room.players.values()].filter(
(p) => p.connected && !p.isBot && p.id !== submittedBy (p) => p.connected && !p.isBot
) )
if (eligible.length === 0) { if (eligible.length === 0) {
return null return null
@ -77,13 +81,14 @@ export class BlindtestRound implements GameRound {
start(room: ServerRoom): RoundStart { start(room: ServerRoom): RoundStart {
const track = takeTrack(room) const track = takeTrack(room)
// DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle. // DJ = n'importe quel humain (le contributeur peut tomber DJ).
const djId = track ? pickDj(room, track.submittedBy) : null const djId = track ? pickDj(room) : null
const payload: BlindtestRoundPayload | null = track const payload: BlindtestRoundPayload | null = track
? { trackId: track.id, youtubeId: track.youtubeId } ? { trackId: track.id, youtubeId: track.youtubeId }
: null : null
return { return {
djId, djId,
durationSec: BLINDTEST_DURATION_SEC,
payload, payload,
secretPlayerId: track?.submittedBy, secretPlayerId: track?.submittedBy,
data: track ? { track } : null, data: track ? { track } : null,

View file

@ -52,6 +52,18 @@ export function registerRoomHandlers(
io.to(room.code).emit("room:state", rooms.toSnapshot(room)) 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) => { socket.on("room:create", (payload, ack) => {
// Le pseudo est choisi dans la room (player:setName) : on crée sans nom. // Le pseudo est choisi dans la room (player:setName) : on crée sans nom.
const name = cleanName(payload?.playerName) ?? "" const name = cleanName(payload?.playerName) ?? ""
@ -111,6 +123,7 @@ export function registerRoomHandlers(
}) })
io.to(room.code).emit("player:rejoined", { playerId: player.id }) io.to(room.code).emit("player:rejoined", { playerId: player.id })
broadcastState(room) broadcastState(room)
emitMyTracks(room, player.id)
// Re-pousse la manche en cours au socket reconnecté (si partie en cours). // Re-pousse la manche en cours au socket reconnecté (si partie en cours).
room.game?.resync(socket.id, player.id) room.game?.resync(socket.id, player.id)
}) })
@ -293,6 +306,7 @@ export function registerRoomHandlers(
title: meta.title, title: meta.title,
count: mine.length + 1, count: mine.length + 1,
}) })
emitMyTracks(room, playerId)
broadcastState(room) broadcastState(room)
}) })
@ -311,6 +325,7 @@ export function registerRoomHandlers(
const removed = room.blindtestTracks.length < before const removed = room.blindtestTracks.length < before
ack({ ok: removed }) ack({ ok: removed })
if (removed) { if (removed) {
emitMyTracks(room, playerId)
broadcastState(room) broadcastState(room)
} }
}) })

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { Disc3, Pause, Play, Rewind } from "lucide-react" import { Disc3, Pause, Play, Rewind, Volume2 } from "lucide-react"
import type { import type {
BlindtestAnswer, BlindtestAnswer,
BlindtestMode, BlindtestMode,
@ -15,6 +15,7 @@ import { useYoutube, type YoutubeApi } from "@/lib/youtube"
import { Countdown } from "@/components/countdown" import { Countdown } from "@/components/countdown"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
import { useI18n } from "@/i18n/context" import { useI18n } from "@/i18n/context"
import { serverNow } from "@/lib/clock"
const inputClass = 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" "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 truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
const showReveal = truth !== 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). // Non-DJ : on suit le DJ via media:sync (avec compensation de latence).
useEffect(() => { useEffect(() => {
if (isDj || !ready || !mediaSync) { if (isDj || !ready || !mediaSync) {
return return
} }
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 const elapsed = (serverNow() - mediaSync.atServerTs) / 1000
if (mediaSync.action === "play") { if (mediaSync.action === "play") {
api.seek(mediaSync.positionSec + Math.max(0, elapsed)) api.seek(mediaSync.positionSec + Math.max(0, elapsed))
api.play() if (audioArmed) {
api.play()
}
} else if (mediaSync.action === "pause") { } else if (mediaSync.action === "pause") {
api.seek(mediaSync.positionSec) api.seek(mediaSync.positionSec)
api.pause() api.pause()
} else { } else {
api.seek(mediaSync.positionSec) 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. // Au reveal, on coupe le son.
useEffect(() => { useEffect(() => {
@ -98,8 +133,36 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
</motion.div> </motion.div>
</div> </div>
)} )}
{/* Non-DJ : geste requis pour débloquer l'audio (autoplay policy). */}
{!showReveal && !isDj && !audioArmed && (
<button
onClick={armAudio}
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-black/60 text-white backdrop-blur-sm"
>
<Volume2 className="size-10" />
<span className="font-heading font-bold">{t.blindtest.enableSound}</span>
</button>
)}
</div> </div>
{!showReveal && (
<div className="flex items-center gap-2">
<Volume2 className="text-muted-foreground size-4 shrink-0" />
<input
type="range"
min={0}
max={100}
value={volume}
onChange={(e) => changeVolume(Number(e.target.value))}
className="accent-primary flex-1"
aria-label={t.blindtest.volume}
/>
<span className="text-muted-foreground w-8 text-right text-xs tabular-nums">
{volume}
</span>
</div>
)}
{isDj && !showReveal && ( {isDj && !showReveal && (
<DjControls ready={ready} api={api} mediaControl={mediaControl} /> <DjControls ready={ready} api={api} mediaControl={mediaControl} />
)} )}

View file

@ -1,15 +1,17 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { serverNow } from "@/lib/clock"
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
/** /**
* Secondes restantes. Avant `startsAt` (phase de préparation / transition), * Secondes restantes, basées sur l'horloge serveur (serverNow) pour que tous les
* on affiche la durée pleine sans décompter. * 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 { function secondsLeft(startsAt: number, endsAt: number): number {
const now = Date.now() const now = serverNow()
if (now < startsAt) { if (now < startsAt) {
return Math.ceil((endsAt - startsAt) / 1000) return Math.ceil((endsAt - startsAt) / 1000)
} }

View file

@ -550,12 +550,6 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
) )
} }
interface MyTrack {
trackId: string
title: string
youtubeId: string
}
function TrackSubmission({ function TrackSubmission({
tracksPerPlayer, tracksPerPlayer,
myCount, myCount,
@ -565,11 +559,12 @@ function TrackSubmission({
}) { }) {
const submitTrack = useRoomStore((s) => s.submitTrack) const submitTrack = useRoomStore((s) => s.submitTrack)
const removeTrack = useRoomStore((s) => s.removeTrack) 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 { t } = useI18n()
const [url, setUrl] = useState("") const [url, setUrl] = useState("")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null) const [feedback, setFeedback] = useState<string | null>(null)
const [tracks, setTracks] = useState<MyTrack[]>([])
const quotaReached = myCount >= tracksPerPlayer const quotaReached = myCount >= tracksPerPlayer
async function submit() { async function submit() {
@ -579,11 +574,8 @@ function TrackSubmission({
setSubmitting(true) setSubmitting(true)
setFeedback(null) setFeedback(null)
const res = await submitTrack(url.trim()) const res = await submitTrack(url.trim())
if (res.accepted && res.trackId && res.youtubeId) { if (res.accepted) {
setTracks((t) => [ // La liste se met à jour via l'event serveur blindtest:myTracks.
...t,
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
])
setUrl("") setUrl("")
} else { } else {
setFeedback(res.reason ?? t.lobby.rejected) setFeedback(res.reason ?? t.lobby.rejected)
@ -592,10 +584,7 @@ function TrackSubmission({
} }
async function remove(trackId: string) { async function remove(trackId: string) {
const ok = await removeTrack(trackId) await removeTrack(trackId)
if (ok) {
setTracks((t) => t.filter((x) => x.trackId !== trackId))
}
} }
return ( return (
@ -604,9 +593,9 @@ function TrackSubmission({
{t.lobby.myTracks(myCount, tracksPerPlayer)} {t.lobby.myTracks(myCount, tracksPerPlayer)}
</span> </span>
{tracks.length > 0 && ( {myTracks.length > 0 && (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{tracks.map((track) => ( {myTracks.map((track) => (
<li <li
key={track.trackId} key={track.trackId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2" className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"

View file

@ -92,7 +92,12 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
)} )}
{isText ? ( {isText ? (
<FreeAnswer disabled={showReveal || hasVoted} onSubmit={voteText} t={t} /> <FreeAnswer
key={round.endsAt}
disabled={showReveal || hasVoted}
onSubmit={voteText}
t={t}
/>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{(question.choices ?? []).map((choice, index) => ( {(question.choices ?? []).map((choice, index) => (

View file

@ -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 (
<div className={`flex gap-1 ${className}`}>
{OPTIONS.map(({ value, Icon }) => (
<button
key={value}
onClick={() => setTheme(value)}
title={value}
className={`rounded-md p-1.5 transition-colors ${
theme === value
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Icon className="size-4" />
</button>
))}
</div>
)
}

View file

@ -109,6 +109,8 @@ export const en: Dict = {
validate: "Submit", validate: "Submit",
}, },
blindtest: { blindtest: {
enableSound: "Enable sound",
volume: "Volume",
djHint: "You're the DJ 🎧 — control playback (and vote like everyone)", djHint: "You're the DJ 🎧 — control playback (and vote like everyone)",
play: "Play", play: "Play",
pause: "Pause", pause: "Pause",

View file

@ -107,6 +107,8 @@ export const fr = {
validate: "Valider", validate: "Valider",
}, },
blindtest: { blindtest: {
enableSound: "Activer le son",
volume: "Volume",
djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)", djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)",
play: "Play", play: "Play",
pause: "Pause", pause: "Pause",

45
apps/web/src/lib/clock.ts Normal file
View file

@ -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<number>((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<void> {
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()
}

View file

@ -29,6 +29,8 @@ export interface YoutubeApi {
seek: (seconds: number) => void seek: (seconds: number) => void
time: () => number time: () => number
duration: () => number duration: () => number
/** Volume 0100. */
setVolume: (v: number) => void
} }
/** /**
@ -65,6 +67,10 @@ export function useYoutube(youtubeId: string) {
modestbranding: 1, modestbranding: 1,
rel: 0, rel: 0,
playsinline: 1, 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: { events: {
onReady: () => { onReady: () => {
@ -91,6 +97,7 @@ export function useYoutube(youtubeId: string) {
seek: (seconds) => playerRef.current?.seekTo(seconds, true), seek: (seconds) => playerRef.current?.seekTo(seconds, true),
time: () => playerRef.current?.getCurrentTime() ?? 0, time: () => playerRef.current?.getCurrentTime() ?? 0,
duration: () => playerRef.current?.getDuration() ?? 0, duration: () => playerRef.current?.getDuration() ?? 0,
setVolume: (v) => playerRef.current?.setVolume(v),
}), }),
[] []
) )

View file

@ -7,6 +7,7 @@ import { useRoomStore } from "@/store/room"
import { useI18n } from "@/i18n/context" import { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error" import { errorMessage } from "@/i18n/error"
import { LanguageToggle } from "@/i18n/language-toggle" import { LanguageToggle } from "@/i18n/language-toggle"
import { ThemeToggle } from "@/components/theme-toggle"
const inputClass = 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" "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 ( return (
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6"> <div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
<LanguageToggle className="absolute top-4 right-4 z-10" /> <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
<ThemeToggle />
<LanguageToggle />
</div>
{/* Halo animé */} {/* Halo animé */}
<motion.div <motion.div
aria-hidden aria-hidden

View file

@ -19,6 +19,7 @@ import { RoundTransition } from "@/components/round-transition"
import { PseudoScreen } from "@/components/pseudo-screen" import { PseudoScreen } from "@/components/pseudo-screen"
import { useI18n } from "@/i18n/context" import { useI18n } from "@/i18n/context"
import { LanguageToggle } from "@/i18n/language-toggle" import { LanguageToggle } from "@/i18n/language-toggle"
import { ThemeToggle } from "@/components/theme-toggle"
export function RoomPage({ code }: { code: string }) { export function RoomPage({ code }: { code: string }) {
const [, navigate] = useLocation() const [, navigate] = useLocation()
@ -100,6 +101,7 @@ export function RoomPage({ code }: { code: string }) {
{connected ? t.room.connected : t.room.disconnected} {connected ? t.room.connected : t.room.disconnected}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<ThemeToggle />
<LanguageToggle /> <LanguageToggle />
<Button <Button
size="sm" size="sm"
@ -129,7 +131,15 @@ export function RoomPage({ code }: { code: string }) {
<PlayerCards <PlayerCards
snapshot={snapshot} snapshot={snapshot}
playerId={playerId} playerId={playerId}
showVoteStatus={snapshot.status === "in_round"} // En blindtest "qui l'a ajouté" (et mixte), on cache l'état de
// vote : le contributeur ne vote jamais → sinon il est repérable.
showVoteStatus={
snapshot.status === "in_round" &&
!(
round?.type === "blindtest" &&
snapshot.settings.blindtestMode !== "title_artist"
)
}
votedIds={voteProgress?.voted} votedIds={voteProgress?.voted}
/> />
{round?.type === "blindtest" ? ( {round?.type === "blindtest" ? (

View file

@ -6,6 +6,7 @@ import {
type ErrorPayload, type ErrorPayload,
type MediaControlPayload, type MediaControlPayload,
type MediaSyncPayload, type MediaSyncPayload,
type MyTrackInfo,
type PlayerScore, type PlayerScore,
type RoomCreatedPayload, type RoomCreatedPayload,
type RoomSnapshot, type RoomSnapshot,
@ -17,6 +18,7 @@ import {
type VoteAckPayload, type VoteAckPayload,
} from "@nerdware/shared" } from "@nerdware/shared"
import { socket } from "@/lib/socket" import { socket } from "@/lib/socket"
import "@/lib/clock" // démarre la synchro d'horloge avec le serveur
import { import {
clearSession, clearSession,
getSession, getSession,
@ -51,6 +53,8 @@ interface RoomState {
pseudoSet: boolean pseudoSet: boolean
snapshot: RoomSnapshot | null snapshot: RoomSnapshot | null
lastError: ErrorPayload | 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). // État de jeu (poussé par le moteur serveur).
round: ActiveRound | null round: ActiveRound | null
@ -96,6 +100,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
pseudoSet: !!saved?.name, pseudoSet: !!saved?.name,
snapshot: null, snapshot: null,
lastError: null, lastError: null,
myTracks: [],
round: null, round: null,
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
@ -242,6 +247,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
pseudoSet: false, pseudoSet: false,
snapshot: null, snapshot: null,
lastError: null, lastError: null,
myTracks: [],
round: null, round: null,
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
@ -303,6 +309,9 @@ socket.on("room:state", (snapshot) =>
) )
) )
socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("blindtest:myTracks", ({ tracks }) =>
useRoomStore.setState({ myTracks: tracks })
)
socket.on("round:start", (payload) => socket.on("round:start", (payload) =>
useRoomStore.setState((s) => { useRoomStore.setState((s) => {

View file

@ -24,6 +24,7 @@ declare global {
getCurrentTime(): number getCurrentTime(): number
getDuration(): number getDuration(): number
getPlayerState(): number getPlayerState(): number
setVolume(volume: number): void
loadVideoById(id: string): void loadVideoById(id: string): void
destroy(): void destroy(): void
} }

View file

@ -34,7 +34,12 @@ services:
NODE_ENV: production NODE_ENV: production
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: 3001 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} ADMIN_TOKEN: ${ADMIN_TOKEN}
CORS_ORIGINS: ${CORS_ORIGINS} CORS_ORIGINS: ${CORS_ORIGINS}
UPLOADS_DIR: /data/uploads UPLOADS_DIR: /data/uploads

View file

@ -78,6 +78,17 @@ export interface RemoveTrackPayload {
trackId: string 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 { export interface RoundStartPayload {
type: RoundType type: RoundType
djId?: string djId?: string
@ -147,6 +158,7 @@ export type Ack<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorP
/** CLIENT → SERVER */ /** CLIENT → SERVER */
export interface ClientToServerEvents { export interface ClientToServerEvents {
"time:sync": (ack: (serverNow: number) => void) => void
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void "room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void "room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void "room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
@ -175,6 +187,7 @@ export interface ServerToClientEvents {
"player:left": (payload: PlayerRefPayload) => void "player:left": (payload: PlayerRefPayload) => void
"player:rejoined": (payload: PlayerRefPayload) => void "player:rejoined": (payload: PlayerRefPayload) => void
"blindtest:submitOk": (payload: SubmitOkPayload) => void "blindtest:submitOk": (payload: SubmitOkPayload) => void
"blindtest:myTracks": (payload: MyTracksPayload) => void
"round:start": (payload: RoundStartPayload) => void "round:start": (payload: RoundStartPayload) => void
"media:sync": (payload: MediaSyncPayload) => void "media:sync": (payload: MediaSyncPayload) => void
"round:voteAck": (payload: VoteAckPayload) => void "round:voteAck": (payload: VoteAckPayload) => void