nerdware/apps/web/src/components/blindtest-view.tsx
AyoubBenziza 879bc5b388 feat: per-submode mixed settings, fastest award, wider desktop layout
Mixed settings fix:
- RoundConfig carries a `pool` hint (quiz | image) per quiz round; lobby shows
  a separate count for each selected sub-mode (Quiz, Images) and the blindtest
  config — not just the quiz one
- server pool composes the queue per round's pool (quiz formats vs image_reveal),
  in order; verified e2e (2 quiz + 2 images honored)

Awards:
- " Le plus rapide" — derived client-side from the speed bonus baked into
  quiz/image points (points above the 100 base on correct answers)

Desktop restyle:
- room page gets an ambient halo background and a responsive container
  (lobby max-w-3xl with a 2-column players|settings grid, game/end max-w-lg)
- game/end views widened to max-w-lg

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:10:09 +02:00

348 lines
10 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, type YoutubeApi } 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"
function fmt(s: number): string {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, "0")}`
}
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])
return (
<div className="flex w-full max-w-lg 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>
{/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la
manche (son seulement), puis révélé (bannière visible) au reveal. */}
<div className="bg-card relative aspect-video w-full overflow-hidden rounded-2xl">
<div
ref={hostRef}
className="absolute inset-0 [&>iframe]:absolute [&>iframe]:inset-0 [&>iframe]:size-full"
/>
{!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 && (
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
)}
{!showReveal &&
(round?.mine ? (
<MisleadCard mode={snapshot.settings.blindtestMode} />
) : (
<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 DjControls({
ready,
api,
mediaControl,
}: {
ready: boolean
api: YoutubeApi
mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void
}) {
const [pos, setPos] = useState(0)
const [dur, setDur] = useState(0)
useEffect(() => {
if (!ready) {
return
}
const id = setInterval(() => {
setPos(api.time())
setDur(api.duration())
}, 400)
return () => clearInterval(id)
}, [ready, api])
return (
<div className="flex flex-col gap-3">
<span className="text-muted-foreground text-center text-xs uppercase">
Tu es le DJ 🎧 pilote la lecture (et vote comme les autres)
</span>
<div className="flex justify-center gap-2">
<Button
size="sm"
disabled={!ready}
onClick={() => {
api.play()
mediaControl("play", api.time())
}}
>
<Play /> Play
</Button>
<Button
size="sm"
variant="secondary"
disabled={!ready}
onClick={() => {
api.pause()
mediaControl("pause", api.time())
}}
>
<Pause /> Pause
</Button>
<Button
size="sm"
variant="secondary"
disabled={!ready}
onClick={() => {
api.seek(0)
setPos(0)
mediaControl("seek", 0)
}}
>
<Rewind /> Début
</Button>
</div>
<div className="flex items-center gap-2 text-xs tabular-nums">
<span className="text-muted-foreground w-9 text-right">{fmt(pos)}</span>
<input
type="range"
min={0}
max={Math.max(dur, 1)}
step="any"
value={Math.min(pos, dur || 0)}
disabled={!ready}
onChange={(e) => {
const v = Number(e.target.value)
setPos(v)
api.seek(v)
mediaControl("seek", v)
}}
className="accent-primary flex-1"
/>
<span className="text-muted-foreground w-9">{fmt(dur)}</span>
</div>
</div>
)
}
function MisleadCard({ mode }: { mode: BlindtestMode }) {
return (
<div className="border-primary/40 bg-primary/5 flex flex-col items-center gap-2 rounded-xl border p-4 text-center">
<span className="text-2xl">🤫</span>
<p className="font-heading font-bold">C'est ton titre !</p>
<p className="text-muted-foreground text-sm">
{mode === "title_artist"
? "Tu ne votes pas. Savoure pendant que les autres cherchent."
: "Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé."}
</p>
</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
.filter((p) => p.id !== playerId)
.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}
</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>
)
}