nerdware/apps/web/src/components/blindtest-view.tsx
AyoubBenziza 98e06b8206 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>
2026-06-10 13:02:28 +02:00

264 lines
7.8 KiB
TypeScript

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