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>
264 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|