- DJ is now the track's contributor (controls their own song); their identity is sent only to them so "who added" stays non-trivial. The contributor doesn't vote and is excluded from the eligible-voters count. - Scoring: the contributor earns nothing except in who_added/mixed, where they get a misdirection bonus (50) per player who guesses the wrong adder. - Blindtest (and mixed) require >= 3 connected players: lobby disables the options with a hint, and game:start rejects with NEED_THREE. - Player card is now 16:9 and full width — the video banner shows at reveal; DJ gets a seek bar (current/duration) to jump anywhere, plus play/pause/restart. - Mixed game order is shuffled (no predictable quiz/blindtest alternation). Verified e2e (3 players): guard rejects at 2, DJ id hidden from others, misdirection scoring correct. 23 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
337 lines
9.6 KiB
TypeScript
337 lines
9.6 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-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>
|
|
|
|
{/* 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} />
|
|
)}
|
|
|
|
{!isDj && !showReveal && (
|
|
<VoteForm
|
|
mode={snapshot.settings.blindtestMode}
|
|
snapshot={snapshot}
|
|
playerId={playerId}
|
|
disabled={hasVoted}
|
|
onVote={voteBlindtest}
|
|
/>
|
|
)}
|
|
|
|
{showReveal && truth && (
|
|
<RevealCard
|
|
truth={truth}
|
|
isDj={isDj}
|
|
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 🎧 — c'est ton titre, fais-le deviner 🤫
|
|
</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 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,
|
|
isDj,
|
|
result,
|
|
}: {
|
|
truth: BlindtestRevealTruth
|
|
isDj: boolean
|
|
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 && (result.points > 0 || !isDj) && (
|
|
<p
|
|
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
|
|
>
|
|
{result.points > 0
|
|
? `+${result.points} points ${isDj ? "🤫" : "🎉"}`
|
|
: "Raté 💥"}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|