feat(blindtest): YouTube blindtest mode with neutral DJ + synced playback

Server:
- BlindtestRound (GameRound): neutral DJ pick (never the contributor),
  per-mode scoring (title_artist fuzzy / who_added / mixed), reveal exposes
  title/artist/submittedBy; contributor doesn't score on own track
- track submission: blindtest:submitTrack validates via YouTube oEmbed
  (exists + embeddable) and captures title/artist; per-player quota; dedup
- DJ media relay: media:control (DJ only) → media:sync broadcast (engine)
- per-room shuffled track pool; submissions count in room snapshot
- shared: blindtest payloads, gameType/tracksPerPlayer in settings
- tests: DJ neutrality, scoring per mode, fuzzy match, youtube id extraction

Client:
- YouTube IFrame player (hidden behind a spinning disc — audio only, no
  title leak), DJ transport (play/pause/restart), non-DJ follows media:sync
  with latency compensation
- lobby: game-type switch (Quiz/Blindtest), blindtest config + per-player
  track submission UI
- vote forms per mode; reveal card with title/artist/who-added

Roadmap V1 step 6 (highest technical risk). Server flow verified end-to-end
over the wire (submit→start→DJ relay→vote→reveal→score).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-10 13:02:28 +02:00
parent 0c940fd447
commit 98e06b8206
22 changed files with 1238 additions and 48 deletions

View file

@ -2,7 +2,12 @@
// pour chaque manche start → (votes | timer) → reveal → score, puis game:end. // pour chaque manche start → (votes | timer) → reveal → score, puis game:end.
// Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté. // Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté.
import type { Answer, PlayerScore, RoundConfig } from "@nerdware/shared" import type {
Answer,
MediaControlPayload,
PlayerScore,
RoundConfig,
} from "@nerdware/shared"
import type { IoServer } from "../socket" import type { IoServer } from "../socket"
import type { RoomManager, ServerRoom } from "../rooms" import type { RoomManager, ServerRoom } from "../rooms"
import { createRound as defaultCreateRound, type RoundFactory } from "./registry" import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
@ -87,6 +92,22 @@ export class GameEngine implements RoomGameController {
} }
} }
/** Relais média : seul le DJ de la manche peut piloter la lecture. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void {
if (
!this.runtime ||
this.room.status !== "in_round" ||
playerId !== this.runtime.djId
) {
return
}
this.io.to(this.room.code).emit("media:sync", {
action: payload.action,
positionSec: payload.positionSec,
atServerTs: this.now(),
})
}
private async playRound(config: RoundConfig): Promise<void> { private async playRound(config: RoundConfig): Promise<void> {
const round = this.createRound(config.type) const round = this.createRound(config.type)
const start = round.start(this.room) const start = round.start(this.room)

View file

@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test"
import { RoomManager, type BlindtestTrack } from "../../../rooms"
import type { RoundContext } from "../../round"
import { BlindtestRound } from "./blindtest-round"
import { prepareBlindtestForRoom } from "./pool"
import { fuzzyMatch, normalize } from "./match"
function track(submittedBy: string): BlindtestTrack {
return {
id: "t1",
youtubeId: "abc12345678",
url: "https://youtu.be/abc12345678",
title: "The Legend of Zelda Main Theme",
artist: "Koji Kondo",
submittedBy,
}
}
function ctxFor(
room: ReturnType<RoomManager["create"]>["room"],
t: BlindtestTrack,
mode: "title_artist" | "who_added" | "mixed"
): RoundContext {
room.settings.blindtestMode = mode
return {
room,
djId: null,
votes: new Map(),
startedAt: 0,
endsAt: 10_000,
data: { track: t },
}
}
describe("match", () => {
test("normalize retire accents/ponctuation/casse", () => {
expect(normalize("Pokémon: Rouge & Bleu!")).toBe("pokemon rouge bleu")
})
test("fuzzyMatch tolère fautes et réponses partielles", () => {
expect(fuzzyMatch("zelda", "The Legend of Zelda")).toBe(true)
expect(fuzzyMatch("koji kondo", "Koji Kondo")).toBe(true)
expect(fuzzyMatch("mario", "The Legend of Zelda")).toBe(false)
})
})
describe("BlindtestRound", () => {
test("start tire un DJ neutre (jamais le contributeur)", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
room.blindtestTracks = [track(a.id)]
prepareBlindtestForRoom(room)
const round = new BlindtestRound()
const start = round.start(room)
expect(start.djId).toBe(b.id) // jamais Alice (contributrice)
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
})
test("who_added : points si on devine le bon contributeur", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, b.id, { guessedPlayerId: a.id }) // juste
const deltas = round.score(ctx)
expect(deltas).toEqual([{ playerId: b.id, delta: 100 }])
})
test("title_artist : 60 titre + 40 artiste, fuzzy", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const { player: b } = rooms.join(room.code, "Bob", "sb")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "title_artist")
round.submitAnswer(ctx, b.id, { title: "zelda", artist: "koji kondo" })
const { perPlayerResult } = round.reveal(ctx)
expect(perPlayerResult[b.id]).toMatchObject({
titleCorrect: true,
artistCorrect: true,
points: 100,
})
expect(round.score(ctx)).toEqual([{ playerId: b.id, delta: 100 }])
})
test("le contributeur ne marque pas sur son propre titre", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "who_added")
round.submitAnswer(ctx, a.id, { guessedPlayerId: a.id })
expect(round.score(ctx)).toEqual([])
})
test("reveal expose le titre, l'artiste et le contributeur", () => {
const rooms = new RoomManager()
const { room, player: a } = rooms.create("Alice", "sa")
const t = track(a.id)
const round = new BlindtestRound()
const ctx = ctxFor(room, t, "title_artist")
const { truth } = round.reveal(ctx)
expect(truth.submittedBy).toBe(a.id)
expect(truth.submittedByName).toBe("Alice")
expect(truth.title).toBe("The Legend of Zelda Main Theme")
})
})

View file

@ -0,0 +1,125 @@
// Épreuve Blindtest : un titre = une manche. Un DJ neutre (jamais le
// contributeur) pilote la lecture ; tout le monde vote à l'aveugle.
import type {
Answer,
BlindtestMode,
BlindtestPerPlayerResult,
BlindtestPlayerResult,
BlindtestRevealTruth,
BlindtestRoundPayload,
ScoreDelta,
} from "@nerdware/shared"
import type { GameRound, RoundContext, RoundStart } from "../../round"
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
import { fuzzyMatch } from "./match"
import { takeTrack } from "./pool"
const TITLE_POINTS = 60
const ARTIST_POINTS = 40
const WHO_POINTS = 100
interface BlindtestRoundData {
track: BlindtestTrack
}
/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */
function pickDj(room: ServerRoom, submittedBy: string): string | null {
const eligible = [...room.players.values()].filter(
(p) => p.connected && p.id !== submittedBy
)
if (eligible.length === 0) {
return null
}
return eligible[Math.floor(Math.random() * eligible.length)].id
}
/** Calcule le résultat d'un vote selon le mode. */
function resultFor(
mode: BlindtestMode,
answer: Answer | undefined,
track: BlindtestTrack
): BlindtestPlayerResult {
const a = (answer ?? {}) as {
title?: string
artist?: string
guessedPlayerId?: string
}
let points = 0
const result: BlindtestPlayerResult = { points: 0 }
if (mode === "title_artist" || mode === "mixed") {
result.titleCorrect = a.title ? fuzzyMatch(a.title, track.title) : false
result.artistCorrect = a.artist ? fuzzyMatch(a.artist, track.artist) : false
if (result.titleCorrect) points += TITLE_POINTS
if (result.artistCorrect) points += ARTIST_POINTS
}
if (mode === "who_added" || mode === "mixed") {
result.guessedCorrect = a.guessedPlayerId === track.submittedBy
if (result.guessedCorrect) points += WHO_POINTS
}
result.points = points
return result
}
export class BlindtestRound implements GameRound {
readonly type = "blindtest" as const
start(room: ServerRoom): RoundStart {
const track = takeTrack(room)
const djId = track ? pickDj(room, track.submittedBy) : null
const payload: BlindtestRoundPayload | null = track
? { trackId: track.id, youtubeId: track.youtubeId }
: null
return { djId, payload, data: track ? { track } : null }
}
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
// Premier vote verrouillé (idempotent).
if (ctx.votes.has(playerId)) {
return
}
ctx.votes.set(playerId, answer)
}
reveal(ctx: RoundContext): {
truth: BlindtestRevealTruth
perPlayerResult: BlindtestPerPlayerResult
} {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const perPlayerResult: BlindtestPerPlayerResult = {}
for (const player of ctx.room.players.values()) {
perPlayerResult[player.id] = resultFor(mode, ctx.votes.get(player.id), track)
}
const submitter = ctx.room.players.get(track.submittedBy)
return {
truth: {
title: track.title,
artist: track.artist,
youtubeId: track.youtubeId,
submittedBy: track.submittedBy,
submittedByName: submitter?.name ?? "?",
},
perPlayerResult,
}
}
score(ctx: RoundContext): ScoreDelta[] {
const { track } = ctx.data as BlindtestRoundData
const mode = ctx.room.settings.blindtestMode
const deltas: ScoreDelta[] = []
for (const [playerId, answer] of ctx.votes) {
// Le contributeur ne marque pas sur son propre titre.
if (playerId === track.submittedBy) {
continue
}
const { points } = resultFor(mode, answer, track)
if (points > 0) {
deltas.push({ playerId, delta: points })
}
}
return deltas
}
}

View file

@ -0,0 +1,6 @@
import { registerRound } from "../../registry"
import { BlindtestRound } from "./blindtest-round"
registerRound("blindtest", () => new BlindtestRound())
export { BlindtestRound }

View file

@ -0,0 +1,54 @@
// Matching tolérant pour les réponses libres (titre/artiste).
// Normalisation (minuscules, accents, ponctuation) + distance de Levenshtein.
/** Minuscule, sans accents, sans ponctuation, espaces compactés. */
export function normalize(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "") // accents
.replace(/\(.*?\)|\[.*?\]/g, " ") // contenu entre parenthèses/crochets
.replace(/[^a-z0-9\s]/g, " ") // ponctuation
.replace(/\s+/g, " ")
.trim()
}
/** Distance d'édition de Levenshtein. */
export function levenshtein(a: string, b: string): number {
if (a === b) return 0
if (a.length === 0) return b.length
if (b.length === 0) return a.length
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
for (let i = 1; i <= a.length; i++) {
const curr = [i]
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
}
prev = curr
}
return prev[b.length]
}
/**
* Vrai si `guess` correspond à `truth` avec tolérance :
* - égalité après normalisation, ou
* - une normalisation contient l'autre (réponse partielle), ou
* - distance de Levenshtein ~20% de la longueur (fautes de frappe).
*/
export function fuzzyMatch(guess: string, truth: string): boolean {
const g = normalize(guess)
const t = normalize(truth)
if (!g || !t) {
return false
}
if (g === t) {
return true
}
// Réponse contenue (ex: "zelda" pour "the legend of zelda").
if (g.length >= 3 && (t.includes(g) || g.includes(t))) {
return true
}
const tolerance = Math.floor(Math.max(g.length, t.length) * 0.2)
return levenshtein(g, t) <= tolerance
}

View file

@ -0,0 +1,26 @@
// File d'attente des titres blindtest par room, préchargée (mélangée) au
// lancement de la partie. Le BlindtestRound y pioche un titre par manche.
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
const queueByRoom = new WeakMap<ServerRoom, BlindtestTrack[]>()
function shuffle<T>(items: T[]): T[] {
const arr = [...items]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/** Mélange les titres soumis dans une file pour la partie. */
export function prepareBlindtestForRoom(room: ServerRoom): void {
queueByRoom.set(room, shuffle(room.blindtestTracks))
}
/** Pioche le prochain titre (ou null si la file est vide). */
export function takeTrack(room: ServerRoom): BlindtestTrack | null {
const queue = queueByRoom.get(room)
return queue && queue.length > 0 ? (queue.shift() ?? null) : null
}

View file

@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { extractYoutubeId } from "./youtube"
describe("extractYoutubeId", () => {
test.each([
["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
["https://youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"],
["dQw4w9WgXcQ", "dQw4w9WgXcQ"],
])("extrait l'ID de %s", (url, expected) => {
expect(extractYoutubeId(url)).toBe(expected)
})
test("renvoie null pour une URL non YouTube", () => {
expect(extractYoutubeId("https://example.com")).toBeNull()
expect(extractYoutubeId("")).toBeNull()
})
})

View file

@ -0,0 +1,53 @@
// Extraction d'ID YouTube + vérification via oEmbed (existe + embeddable),
// qui fournit aussi titre et auteur pour le reveal.
/** Extrait l'ID vidéo (11 chars) d'une URL YouTube (watch, youtu.be, shorts, embed). */
export function extractYoutubeId(input: string): string | null {
const url = input.trim()
// ID brut directement collé.
if (/^[\w-]{11}$/.test(url)) {
return url
}
const patterns = [
/[?&]v=([\w-]{11})/, // watch?v=ID
/youtu\.be\/([\w-]{11})/, // youtu.be/ID
/\/shorts\/([\w-]{11})/, // /shorts/ID
/\/embed\/([\w-]{11})/, // /embed/ID
]
for (const re of patterns) {
const m = url.match(re)
if (m) {
return m[1]
}
}
return null
}
export interface YoutubeMeta {
title: string
artist: string
}
/**
* Vérifie via oEmbed que la vidéo existe et est intégrable, et renvoie
* { title, artist }. null si introuvable/privée/embedding désactivé.
*/
export async function fetchYoutubeMeta(
youtubeId: string
): Promise<YoutubeMeta | null> {
const url = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${youtubeId}&format=json`
try {
const res = await fetch(url)
if (!res.ok) {
// 401/404 → vidéo privée, supprimée, ou embedding désactivé.
return null
}
const json = (await res.json()) as { title?: string; author_name?: string }
return {
title: json.title ?? "",
artist: json.author_name ?? "",
}
} catch {
return null
}
}

View file

@ -2,13 +2,19 @@
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici. // pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
import "./quiz" import "./quiz"
import "./blindtest"
import type { ServerRoom } from "../../rooms" import type { ServerRoom } from "../../rooms"
import { prepareQuizForRoom } from "./quiz/pool" import { prepareQuizForRoom } from "./quiz/pool"
import { prepareBlindtestForRoom } from "./blindtest/pool"
/** Précharge le contenu nécessaire avant de lancer la partie (ex: pool quiz). */ /** Précharge le contenu nécessaire avant de lancer la partie. */
export async function prepareRoom(room: ServerRoom): Promise<void> { export async function prepareRoom(room: ServerRoom): Promise<void> {
if (room.settings.rounds.some((r) => r.type === "quiz")) { const types = new Set(room.settings.rounds.map((r) => r.type))
if (types.has("quiz")) {
await prepareQuizForRoom(room) await prepareQuizForRoom(room)
} }
if (types.has("blindtest")) {
prepareBlindtestForRoom(room)
}
} }

View file

@ -1,7 +1,13 @@
// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface : // Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface :
// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie. // brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie.
import type { Answer, RevealPayload, RoundType, ScoreDelta } from "@nerdware/shared" import type {
Answer,
MediaControlPayload,
RevealPayload,
RoundType,
ScoreDelta,
} from "@nerdware/shared"
import type { ServerRoom } from "../rooms" import type { ServerRoom } from "../rooms"
/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */ /** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */
@ -52,4 +58,6 @@ export interface GameRound {
export interface RoomGameController { export interface RoomGameController {
run(): Promise<void> run(): Promise<void>
handleVote(playerId: string, answer: Answer): void handleVote(playerId: string, answer: Answer): void
/** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void
} }

View file

@ -10,9 +10,14 @@ import type {
} from "@nerdware/shared" } from "@nerdware/shared"
import type { IoServer } from "./socket" import type { IoServer } from "./socket"
import { RoomError, RoomManager, type ServerRoom } from "./rooms" import { RoomError, RoomManager, type ServerRoom } from "./rooms"
import { randomUUID } from "node:crypto"
import { GameEngine } from "./game/engine" import { GameEngine } from "./game/engine"
import { hasRound } from "./game/registry" import { hasRound } from "./game/registry"
import { prepareRoom } from "./game/modes" import { prepareRoom } from "./game/modes"
import {
extractYoutubeId,
fetchYoutubeMeta,
} from "./game/modes/blindtest/youtube"
type AppSocket = Socket< type AppSocket = Socket<
ClientToServerEvents, ClientToServerEvents,
@ -91,8 +96,10 @@ export function registerRoomHandlers(
return return
} }
room.settings = { room.settings = {
gameType: payload.gameType,
blindtestMode: payload.blindtestMode, blindtestMode: payload.blindtestMode,
roundDuration: payload.roundDuration, roundDuration: payload.roundDuration,
tracksPerPlayer: payload.tracksPerPlayer,
rounds: payload.rounds, rounds: payload.rounds,
} }
broadcastState(room) broadcastState(room)
@ -133,6 +140,63 @@ export function registerRoomHandlers(
.catch((err) => console.error("[game] run failed", err)) .catch((err) => console.error("[game] run failed", err))
}) })
socket.on("blindtest:submitTrack", async (payload, ack) => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
const room = code ? rooms.get(code) : undefined
if (!room || !playerId || room.status !== "lobby") {
ack({ accepted: false, reason: "Soumission impossible maintenant." })
return
}
const youtubeId = extractYoutubeId(payload?.youtubeUrl ?? "")
if (!youtubeId) {
ack({ accepted: false, reason: "Lien YouTube invalide." })
return
}
const mine = room.blindtestTracks.filter((t) => t.submittedBy === playerId)
if (mine.length >= room.settings.tracksPerPlayer) {
ack({ accepted: false, reason: "Quota de titres atteint." })
return
}
if (room.blindtestTracks.some((t) => t.youtubeId === youtubeId)) {
ack({ accepted: false, reason: "Titre déjà soumis." })
return
}
const meta = await fetchYoutubeMeta(youtubeId)
if (!meta) {
ack({
accepted: false,
reason: "Vidéo introuvable ou non intégrable.",
})
return
}
// Re-vérifie la room après l'await (le joueur a pu se déconnecter).
if (room.status !== "lobby" || !room.players.has(playerId)) {
ack({ accepted: false, reason: "Soumission impossible maintenant." })
return
}
room.blindtestTracks.push({
id: randomUUID(),
youtubeId,
url: payload.youtubeUrl,
title: meta.title,
artist: meta.artist,
submittedBy: playerId,
})
const count = mine.length + 1
ack({ accepted: true, title: meta.title, count })
broadcastState(room)
})
socket.on("media:control", (payload) => {
const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined
if (!room || !room.game || !socket.data.playerId) {
return
}
room.game.handleMediaControl(socket.data.playerId, payload)
})
socket.on("round:vote", (payload) => { socket.on("round:vote", (payload) => {
const code = socket.data.roomCode const code = socket.data.roomCode
const room = code ? rooms.get(code) : undefined const room = code ? rooms.get(code) : undefined

View file

@ -18,6 +18,16 @@ export interface ServerPlayer {
socketId: string | null socketId: string | null
} }
/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */
export interface BlindtestTrack {
id: string
youtubeId: string
url: string
title: string
artist: string
submittedBy: string
}
/** Room côté serveur : état runtime complet (le snapshot public en dérive). */ /** Room côté serveur : état runtime complet (le snapshot public en dérive). */
export interface ServerRoom { export interface ServerRoom {
code: string code: string
@ -27,6 +37,8 @@ export interface ServerRoom {
settings: RoomSettings settings: RoomSettings
scores: Map<string, number> scores: Map<string, number>
currentRound: number currentRound: number
/** Titres soumis pour le blindtest (mélangés au lancement). */
blindtestTracks: BlindtestTrack[]
/** Moteur de jeu actif (null tant qu'on est dans le lobby). */ /** Moteur de jeu actif (null tant qu'on est dans le lobby). */
game: RoomGameController | null game: RoomGameController | null
} }
@ -50,6 +62,7 @@ export class RoomManager {
settings: { ...DEFAULT_ROOM_SETTINGS }, settings: { ...DEFAULT_ROOM_SETTINGS },
scores: new Map([[player.id, 0]]), scores: new Map([[player.id, 0]]),
currentRound: -1, currentRound: -1,
blindtestTracks: [],
game: null, game: null,
} }
this.rooms.set(code, room) this.rooms.set(code, room)
@ -115,6 +128,10 @@ export class RoomManager {
})), })),
currentRound: room.currentRound, currentRound: room.currentRound,
totalRounds: room.settings.rounds.length, totalRounds: room.settings.rounds.length,
submissions: [...room.players.values()].map((p) => ({
playerId: p.id,
count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length,
})),
} }
} }

View file

@ -0,0 +1,264 @@
import { useEffect, useState } from "react"
import { motion } from "framer-motion"
import { Disc3, Pause, Play, Rewind } from "lucide-react"
import type {
BlindtestAnswer,
BlindtestMode,
BlindtestPerPlayerResult,
BlindtestRevealTruth,
BlindtestRoundPayload,
RoomSnapshot,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { useYoutube } from "@/lib/youtube"
import { Countdown } from "@/components/countdown"
import { Avatar } from "@/components/avatar"
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"
export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal)
const mediaSync = useRoomStore((s) => s.mediaSync)
const playerId = useRoomStore((s) => s.playerId)
const hasVoted = useRoomStore((s) => s.hasVoted)
const voteBlindtest = useRoomStore((s) => s.voteBlindtest)
const mediaControl = useRoomStore((s) => s.mediaControl)
const track = round?.payload as BlindtestRoundPayload | undefined
const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "")
const isDj = !!playerId && round?.djId === playerId
const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
const showReveal = truth !== null
// 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
if (mediaSync.action === "play") {
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
api.play()
} else if (mediaSync.action === "pause") {
api.seek(mediaSync.positionSec)
api.pause()
} else {
api.seek(mediaSync.positionSec)
}
}, [mediaSync, isDj, ready, api])
// Au reveal, on coupe le son.
useEffect(() => {
if (showReveal && ready) {
api.pause()
}
}, [showReveal, ready, api])
function djPlay() {
api.play()
mediaControl("play", api.time())
}
function djPause() {
api.pause()
mediaControl("pause", api.time())
}
function djRestart() {
api.seek(0)
mediaControl("seek", 0)
}
return (
<div className="flex w-full max-w-md flex-col gap-5">
<header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase">
Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds}
</span>
{!showReveal && round && (
<Countdown
key={round.endsAt}
startsAt={round.startsAt}
endsAt={round.endsAt}
/>
)}
</header>
{/* Pochette : le lecteur YouTube est caché derrière (son seulement). */}
<div className="relative mx-auto aspect-square w-48 overflow-hidden rounded-2xl">
<div ref={hostRef} className="absolute inset-0" />
{!showReveal && (
<div className="bg-card absolute inset-0 flex items-center justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 4, repeat: Infinity, ease: "linear" }}
>
<Disc3 className="text-muted-foreground size-24" />
</motion.div>
</div>
)}
</div>
{isDj && !showReveal && (
<div className="flex flex-col items-center gap-2">
<span className="text-muted-foreground text-xs uppercase">
Tu es le DJ 🎧
</span>
<div className="flex gap-2">
<Button size="sm" disabled={!ready} onClick={djPlay}>
<Play /> Play
</Button>
<Button size="sm" variant="secondary" disabled={!ready} onClick={djPause}>
<Pause /> Pause
</Button>
<Button size="sm" variant="secondary" disabled={!ready} onClick={djRestart}>
<Rewind /> Début
</Button>
</div>
</div>
)}
{!showReveal && (
<VoteForm
mode={snapshot.settings.blindtestMode}
snapshot={snapshot}
playerId={playerId}
disabled={hasVoted}
onVote={voteBlindtest}
/>
)}
{showReveal && truth && (
<RevealCard
truth={truth}
result={
(reveal?.perPlayerResult as BlindtestPerPlayerResult)[playerId ?? ""]
}
/>
)}
</div>
)
}
function VoteForm({
mode,
snapshot,
playerId,
disabled,
onVote,
}: {
mode: BlindtestMode
snapshot: RoomSnapshot
playerId: string | null
disabled: boolean
onVote: (answer: BlindtestAnswer) => void
}) {
const [title, setTitle] = useState("")
const [artist, setArtist] = useState("")
const [guessed, setGuessed] = useState<string | null>(null)
const needsText = mode === "title_artist" || mode === "mixed"
const needsWho = mode === "who_added" || mode === "mixed"
const canSubmit =
!disabled &&
(!needsText || title.trim().length > 0) &&
(!needsWho || guessed !== null)
function submit() {
const answer: {
title?: string
artist?: string
guessedPlayerId?: string
} = {}
if (needsText) {
answer.title = title.trim()
answer.artist = artist.trim()
}
if (needsWho && guessed) {
answer.guessedPlayerId = guessed
}
onVote(answer as BlindtestAnswer)
}
if (disabled) {
return (
<p className="text-muted-foreground text-center text-xs">
Réponse envoyée en attente des autres
</p>
)
}
return (
<div className="flex flex-col gap-3">
{needsText && (
<>
<input
className={inputClass}
placeholder="Titre"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
className={inputClass}
placeholder="Artiste"
value={artist}
onChange={(e) => setArtist(e.target.value)}
/>
</>
)}
{needsWho && (
<div className="flex flex-wrap justify-center gap-2">
{snapshot.players.map((p) => (
<button
key={p.id}
onClick={() => setGuessed(p.id)}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm ${
guessed === p.id
? "border-primary bg-primary/10"
: "border-input hover:bg-muted/60"
}`}
>
<Avatar seed={p.name} className="size-5" />
{p.name}
{p.id === playerId && (
<span className="text-muted-foreground">(toi)</span>
)}
</button>
))}
</div>
)}
<Button disabled={!canSubmit} onClick={submit}>
Valider ma réponse
</Button>
</div>
)
}
function RevealCard({
truth,
result,
}: {
truth: BlindtestRevealTruth
result?: BlindtestPerPlayerResult[string]
}) {
return (
<div className="flex flex-col items-center gap-2 text-center">
<p className="font-heading text-lg font-bold text-balance">{truth.title}</p>
<p className="text-muted-foreground text-sm">{truth.artist}</p>
<p className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Ajouté par</span>
<Avatar seed={truth.submittedByName} className="size-6" />
<span className="font-medium">{truth.submittedByName}</span>
</p>
{result && (
<p
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
>
{result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"}
</p>
)}
</div>
)
}

View file

@ -1,25 +1,38 @@
import { useState } from "react" import { useState } from "react"
import type { RoomSnapshot } from "@nerdware/shared" import type { BlindtestMode, RoomSnapshot, RoundConfig } from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
const QUESTION_OPTIONS = [3, 5, 10] const QUESTION_OPTIONS = [3, 5, 10]
const TRACKS_OPTIONS = [1, 2, 3]
const MODE_LABELS: Record<BlindtestMode, string> = {
title_artist: "Titre & artiste",
who_added: "Qui l'a ajouté ?",
mixed: "Mixte",
}
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"
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const updateSettings = useRoomStore((s) => s.updateSettings)
const startGame = useRoomStore((s) => s.startGame) const startGame = useRoomStore((s) => s.startGame)
const isHost = snapshot.hostId === playerId const isHost = snapshot.hostId === playerId
const { gameType, blindtestMode, tracksPerPlayer } = snapshot.settings
const [count, setCount] = useState(5) const [count, setCount] = useState(5)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
async function handleStart() { const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0)
async function start(rounds: RoundConfig[]) {
setError(null) setError(null)
setBusy(true) setBusy(true)
try { try {
await startGame(count) await startGame(rounds)
} catch (err) { } catch (err) {
setError((err as { message?: string }).message ?? "Erreur") setError((err as { message?: string }).message ?? "Erreur")
} finally { } finally {
@ -57,7 +70,24 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</ul> </ul>
</section> </section>
{isHost ? ( {/* Choix du type de partie (hôte). */}
{isHost && (
<div className="flex gap-2">
{(["quiz", "blindtest"] as const).map((type) => (
<Button
key={type}
className="flex-1"
variant={gameType === type ? "default" : "secondary"}
onClick={() => updateSettings({ gameType: type })}
>
{type === "quiz" ? "Quiz" : "Blindtest"}
</Button>
))}
</div>
)}
{gameType === "quiz" ? (
isHost ? (
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Questions de quiz</span> <span className="text-sm font-medium">Questions de quiz</span>
@ -74,18 +104,173 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
))} ))}
</div> </div>
</div> </div>
<Button disabled={busy} onClick={handleStart}> <Button
disabled={busy}
onClick={() =>
start(Array.from({ length: count }, () => ({ type: "quiz" })))
}
>
{busy ? "Lancement…" : "Lancer la partie"} {busy ? "Lancement…" : "Lancer la partie"}
</Button> </Button>
{error && (
<p className="text-destructive text-center text-sm">{error}</p>
)}
</section> </section>
) : ( ) : (
<p className="text-muted-foreground text-center text-xs"> <p className="text-muted-foreground text-center text-xs">
En attente du lancement de la partie par l'hôte En attente du lancement par l'hôte
</p> </p>
)
) : (
<BlindtestLobby
snapshot={snapshot}
isHost={isHost}
mode={blindtestMode}
tracksPerPlayer={tracksPerPlayer}
myCount={
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
}
totalTracks={totalTracks}
busy={busy}
onConfig={updateSettings}
onStart={() =>
start(
Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
)
}
/>
)} )}
{error && <p className="text-destructive text-center text-sm">{error}</p>}
</div> </div>
) )
} }
function BlindtestLobby({
isHost,
mode,
tracksPerPlayer,
myCount,
totalTracks,
busy,
onConfig,
onStart,
}: {
snapshot: RoomSnapshot
isHost: boolean
mode: BlindtestMode
tracksPerPlayer: number
myCount: number
totalTracks: number
busy: boolean
onConfig: (partial: {
blindtestMode?: BlindtestMode
tracksPerPlayer?: number
}) => void
onStart: () => void
}) {
const submitTrack = useRoomStore((s) => s.submitTrack)
const [url, setUrl] = useState("")
const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [accepted, setAccepted] = useState<string[]>([])
const quotaReached = myCount >= tracksPerPlayer
async function submit() {
if (!url.trim()) {
return
}
setSubmitting(true)
setFeedback(null)
const res = await submitTrack(url.trim())
if (res.accepted) {
setAccepted((a) => [...a, res.title ?? url.trim()])
setUrl("")
setFeedback(null)
} else {
setFeedback(res.reason ?? "Refusé")
}
setSubmitting(false)
}
return (
<section className="flex flex-col gap-4">
{isHost && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Mode</span>
<div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
<Button
key={m}
size="sm"
className="flex-1"
variant={mode === m ? "default" : "secondary"}
onClick={() => onConfig({ blindtestMode: m })}
>
{MODE_LABELS[m]}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Titres par joueur</span>
<div className="flex gap-1">
{TRACKS_OPTIONS.map((n) => (
<Button
key={n}
size="sm"
variant={tracksPerPlayer === n ? "default" : "secondary"}
onClick={() => onConfig({ tracksPerPlayer: n })}
>
{n}
</Button>
))}
</div>
</div>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Tes titres ({myCount}/{tracksPerPlayer})
</span>
<div className="flex gap-2">
<input
className={inputClass}
placeholder="Lien YouTube"
value={url}
disabled={quotaReached || submitting}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<Button
variant="secondary"
disabled={quotaReached || submitting || !url.trim()}
onClick={submit}
>
Ajouter
</Button>
</div>
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
{accepted.length > 0 && (
<ul className="text-muted-foreground flex flex-col gap-0.5 text-xs">
{accepted.map((t, i) => (
<li key={i} className="truncate">
{t}
</li>
))}
</ul>
)}
</div>
{isHost ? (
<Button disabled={busy || totalTracks === 0} onClick={onStart}>
{busy ? "Lancement…" : `Lancer (${totalTracks} titres)`}
</Button>
) : (
<p className="text-muted-foreground text-center text-xs">
En attente du lancement par l'hôte ({totalTracks} titres soumis)
</p>
)}
</section>
)
}

View file

@ -0,0 +1,97 @@
import { useEffect, useMemo, useRef, useState } from "react"
let apiPromise: Promise<void> | null = null
/** Charge l'API IFrame YouTube une seule fois. */
function loadYoutubeApi(): Promise<void> {
if (window.YT?.Player) {
return Promise.resolve()
}
if (apiPromise) {
return apiPromise
}
apiPromise = new Promise((resolve) => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
resolve()
}
const tag = document.createElement("script")
tag.src = "https://www.youtube.com/iframe_api"
document.head.appendChild(tag)
})
return apiPromise
}
export interface YoutubeApi {
play: () => void
pause: () => void
seek: (seconds: number) => void
time: () => number
}
/**
* Crée un lecteur YouTube pour `youtubeId`. Renvoie un ref à poser sur un div
* hôte, l'état "prêt", et une API impérative (play/pause/seek/time).
*/
export function useYoutube(youtubeId: string) {
const hostRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<YT.Player | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
const host = hostRef.current
if (!host) {
return
}
setReady(false)
// YT remplace l'élément fourni par une iframe → on lui donne un enfant jetable
// pour que React ne gère jamais ce nœud.
const inner = document.createElement("div")
host.appendChild(inner)
loadYoutubeApi().then(() => {
if (cancelled || !window.YT) {
return
}
playerRef.current = new window.YT.Player(inner, {
videoId: youtubeId,
playerVars: {
autoplay: 0,
controls: 0,
disablekb: 1,
modestbranding: 1,
rel: 0,
playsinline: 1,
},
events: {
onReady: () => {
if (!cancelled) {
setReady(true)
}
},
},
})
})
return () => {
cancelled = true
playerRef.current?.destroy()
playerRef.current = null
host.replaceChildren()
}
}, [youtubeId])
const api = useMemo<YoutubeApi>(
() => ({
play: () => playerRef.current?.playVideo(),
pause: () => playerRef.current?.pauseVideo(),
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
time: () => playerRef.current?.getCurrentTime() ?? 0,
}),
[]
)
return { hostRef, ready, api }
}

View file

@ -3,6 +3,7 @@ import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { LobbyView } from "@/components/lobby-view" import { LobbyView } from "@/components/lobby-view"
import { QuizView } from "@/components/quiz-view" import { QuizView } from "@/components/quiz-view"
import { BlindtestView } from "@/components/blindtest-view"
import { GameEndView } from "@/components/game-end-view" import { GameEndView } from "@/components/game-end-view"
import { PlayerCards } from "@/components/player-cards" import { PlayerCards } from "@/components/player-cards"
import { RoomCode } from "@/components/room-code" import { RoomCode } from "@/components/room-code"
@ -16,6 +17,7 @@ export function RoomPage({ code }: { code: string }) {
const boomKey = useRoomStore((s) => s.boomKey) const boomKey = useRoomStore((s) => s.boomKey)
const roundKey = useRoomStore((s) => s.roundKey) const roundKey = useRoomStore((s) => s.roundKey)
const voteProgress = useRoomStore((s) => s.voteProgress) const voteProgress = useRoomStore((s) => s.voteProgress)
const round = useRoomStore((s) => s.round)
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
if (!snapshot || snapshot.code !== code) { if (!snapshot || snapshot.code !== code) {
@ -58,7 +60,12 @@ export function RoomPage({ code }: { code: string }) {
)} )}
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />} {snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
{inGame && <QuizView snapshot={snapshot} />} {inGame &&
(round?.type === "blindtest" ? (
<BlindtestView snapshot={snapshot} />
) : (
<QuizView snapshot={snapshot} />
))}
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />} {snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
</div> </div>

View file

@ -1,11 +1,18 @@
import { create } from "zustand" import { create } from "zustand"
import type { import {
ErrorPayload, DEFAULT_ROOM_SETTINGS,
PlayerScore, type BlindtestAnswer,
RoomCreatedPayload, type ErrorPayload,
RoomSnapshot, type MediaControlPayload,
RoundStartPayload, type MediaSyncPayload,
VoteAckPayload, type PlayerScore,
type RoomCreatedPayload,
type RoomSnapshot,
type RoundConfig,
type RoundStartPayload,
type SubmitOkPayload,
type UpdateSettingsPayload,
type VoteAckPayload,
} from "@nerdware/shared" } from "@nerdware/shared"
import { socket } from "@/lib/socket" import { socket } from "@/lib/socket"
@ -26,7 +33,6 @@ export interface RoundReveal {
interface RoomState { interface RoomState {
connected: boolean connected: boolean
/** Identité locale, pour reconnaître "moi" dans le snapshot. */
playerId: string | null playerId: string | null
roomCode: string | null roomCode: string | null
snapshot: RoomSnapshot | null snapshot: RoomSnapshot | null
@ -37,16 +43,20 @@ interface RoomState {
voteProgress: VoteAckPayload | null voteProgress: VoteAckPayload | null
reveal: RoundReveal | null reveal: RoundReveal | null
myChoiceIndex: number | null myChoiceIndex: number | null
hasVoted: boolean
finalScores: PlayerScore[] | null finalScores: PlayerScore[] | null
/** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */ mediaSync: MediaSyncPayload | null
boomKey: number boomKey: number
/** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */
roundKey: number roundKey: number
createRoom: (playerName: string) => Promise<RoomCreatedPayload> createRoom: (playerName: string) => Promise<RoomCreatedPayload>
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload> joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
startGame: (questionCount: number) => Promise<void> updateSettings: (partial: Partial<UpdateSettingsPayload>) => void
startGame: (rounds: RoundConfig[]) => Promise<void>
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
vote: (choiceIndex: number) => void vote: (choiceIndex: number) => void
voteBlindtest: (answer: BlindtestAnswer) => void
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
boom: () => void boom: () => void
clearError: () => void clearError: () => void
reset: () => void reset: () => void
@ -62,7 +72,9 @@ export const useRoomStore = create<RoomState>((set, get) => ({
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
myChoiceIndex: null, myChoiceIndex: null,
hasVoted: false,
finalScores: null, finalScores: null,
mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
@ -92,17 +104,20 @@ export const useRoomStore = create<RoomState>((set, get) => ({
}) })
}), }),
startGame: (questionCount) => updateSettings: (partial) => {
new Promise((resolve, reject) => { const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
const rounds = Array.from({ length: questionCount }, () => ({
type: "quiz" as const,
}))
// On pousse la séquence de manches, puis on lance.
socket.emit("lobby:updateSettings", { socket.emit("lobby:updateSettings", {
blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist", gameType: partial.gameType ?? current.gameType,
roundDuration: 20, blindtestMode: partial.blindtestMode ?? current.blindtestMode,
rounds, roundDuration: partial.roundDuration ?? current.roundDuration,
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
rounds: partial.rounds ?? current.rounds,
}) })
},
startGame: (rounds) =>
new Promise((resolve, reject) => {
get().updateSettings({ rounds })
socket.emit("game:start", (res) => { socket.emit("game:start", (res) => {
if (res.ok) { if (res.ok) {
resolve() resolve()
@ -113,14 +128,31 @@ export const useRoomStore = create<RoomState>((set, get) => ({
}) })
}), }),
submitTrack: (youtubeUrl) =>
new Promise((resolve) => {
socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res))
}),
vote: (choiceIndex) => { vote: (choiceIndex) => {
if (get().myChoiceIndex !== null) { if (get().hasVoted) {
return // vote déjà émis pour cette manche return
} }
set({ myChoiceIndex: choiceIndex }) set({ myChoiceIndex: choiceIndex, hasVoted: true })
socket.emit("round:vote", { answer: { choiceIndex } }) socket.emit("round:vote", { answer: { choiceIndex } })
}, },
voteBlindtest: (answer) => {
if (get().hasVoted) {
return
}
set({ hasVoted: true })
socket.emit("round:vote", { answer })
},
mediaControl: (action, positionSec) => {
socket.emit("media:control", { action, positionSec })
},
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })), boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
clearError: () => set({ lastError: null }), clearError: () => set({ lastError: null }),
@ -133,7 +165,9 @@ export const useRoomStore = create<RoomState>((set, get) => ({
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
myChoiceIndex: null, myChoiceIndex: null,
hasVoted: false,
finalScores: null, finalScores: null,
mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
}), }),
@ -157,6 +191,8 @@ socket.on("round:start", (payload) =>
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
myChoiceIndex: null, myChoiceIndex: null,
hasVoted: false,
mediaSync: null,
roundKey: s.roundKey + 1, roundKey: s.roundKey + 1,
})) }))
) )
@ -164,6 +200,7 @@ socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress }) useRoomStore.setState({ voteProgress })
) )
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal })) socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
socket.on("game:end", ({ finalScores }) => socket.on("game:end", ({ finalScores }) =>
useRoomStore.setState({ finalScores }) useRoomStore.setState({ finalScores })
) )

44
apps/web/src/types/youtube.d.ts vendored Normal file
View file

@ -0,0 +1,44 @@
// Déclarations minimales de l'API YouTube IFrame Player (ce qu'on utilise).
export {}
declare global {
namespace YT {
interface PlayerEvent {
target: Player
data: number
}
interface PlayerOptions {
videoId?: string
playerVars?: Record<string, string | number>
events?: {
onReady?: (e: PlayerEvent) => void
onStateChange?: (e: PlayerEvent) => void
}
}
class Player {
constructor(el: HTMLElement | string, opts: PlayerOptions)
playVideo(): void
pauseVideo(): void
seekTo(seconds: number, allowSeekAhead: boolean): void
getCurrentTime(): number
getDuration(): number
getPlayerState(): number
loadVideoById(id: string): void
destroy(): void
}
const PlayerState: {
UNSTARTED: -1
ENDED: 0
PLAYING: 1
PAUSED: 2
BUFFERING: 3
CUED: 5
}
}
interface Window {
YT?: typeof YT
onYouTubeIframeAPIReady?: () => void
}
}

View file

@ -0,0 +1,28 @@
// Types concrets de l'épreuve Blindtest, partagés client/serveur.
// L'audio est joué localement par chaque client mais piloté par le DJ (media:sync).
/** Payload diffusé au lancement d'une manche blindtest (round:start), SANS la réponse. */
export interface BlindtestRoundPayload {
trackId: string
youtubeId: string
}
/** Vérité révélée à tous (round:reveal → truth), inclut qui a soumis le titre. */
export interface BlindtestRevealTruth {
title: string
artist: string
youtubeId: string
submittedBy: string
submittedByName: string
}
/** Résultat d'un joueur sur la manche (détail selon le mode). */
export interface BlindtestPlayerResult {
titleCorrect?: boolean
artistCorrect?: boolean
guessedCorrect?: boolean
points: number
}
/** round:reveal → perPlayerResult : playerId → résultat. */
export type BlindtestPerPlayerResult = Record<string, BlindtestPlayerResult>

View file

@ -27,17 +27,23 @@ export interface RoundConfig {
/** Réglages de la room, modifiables dans le lobby par l'hôte. */ /** Réglages de la room, modifiables dans le lobby par l'hôte. */
export interface RoomSettings { export interface RoomSettings {
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
gameType: RoundType
blindtestMode: BlindtestMode blindtestMode: BlindtestMode
/** Durée d'une manche en secondes (def. 60). */ /** Durée d'une manche en secondes (def. 60). */
roundDuration: number roundDuration: number
/** Nombre de titres que chaque joueur soumet (blindtest). */
tracksPerPlayer: number
/** Séquence d'épreuves de la partie. */ /** Séquence d'épreuves de la partie. */
rounds: RoundConfig[] rounds: RoundConfig[]
} }
/** Réglages par défaut d'une nouvelle room. */ /** Réglages par défaut d'une nouvelle room. */
export const DEFAULT_ROOM_SETTINGS: RoomSettings = { export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
gameType: "quiz",
blindtestMode: "title_artist", blindtestMode: "title_artist",
roundDuration: 60, roundDuration: 60,
tracksPerPlayer: 2,
rounds: [], rounds: [],
} }
@ -86,4 +92,6 @@ export interface RoomSnapshot {
currentRound: number currentRound: number
/** Nombre total de manches planifiées. */ /** Nombre total de manches planifiées. */
totalRounds: number totalRounds: number
/** Blindtest : nombre de titres soumis par joueur (comptes seulement, jamais les titres). */
submissions: { playerId: string; count: number }[]
} }

View file

@ -27,8 +27,10 @@ export interface RoomJoinPayload {
} }
export interface UpdateSettingsPayload { export interface UpdateSettingsPayload {
gameType: RoundType
blindtestMode: BlindtestMode blindtestMode: BlindtestMode
roundDuration: number roundDuration: number
tracksPerPlayer: number
rounds: RoundConfig[] rounds: RoundConfig[]
} }
@ -43,6 +45,10 @@ export interface SubmitTrackPayload {
export interface SubmitOkPayload { export interface SubmitOkPayload {
accepted: boolean accepted: boolean
reason?: string reason?: string
/** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */
title?: string
/** Nombre total de titres soumis par ce joueur après cet ajout. */
count?: number
} }
export interface RoundStartPayload { export interface RoundStartPayload {
@ -112,7 +118,10 @@ export interface ClientToServerEvents {
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void "room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void
"game:start": (ack: Ack<null>) => void "game:start": (ack: Ack<null>) => void
"blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack<SubmitOkPayload>) => void "blindtest:submitTrack": (
payload: SubmitTrackPayload,
ack: (res: SubmitOkPayload) => void
) => void
"media:control": (payload: MediaControlPayload) => void "media:control": (payload: MediaControlPayload) => void
"round:vote": (payload: VotePayload) => void "round:vote": (payload: VotePayload) => void
} }

View file

@ -1,3 +1,4 @@
export * from "./domain" export * from "./domain"
export * from "./events" export * from "./events"
export * from "./quiz" export * from "./quiz"
export * from "./blindtest"