fix(web): blindtest sound, hidden vote status, authoritative tracks, quiz input

From friends playtest feedback:
- blindtest "who added it?" (and mixed): hide the per-player vote status — the
  contributor never validates, which gave them away
- blindtest sound: set YT player `origin`/`enablejsapi` (fixes the postMessage
  origin warning) and add a per-game "Activer le son" gesture for non-DJ
  (browsers block audible autoplay without a user gesture)
- track list: render each player's own tracks from a server-sent, authoritative
  blindtest:myTracks event (emitted on submit/remove/reconnect) instead of local
  state that desynced on remount — fixes the "3/3 but only 2 shown" case
- quiz: reset the free-answer input between questions (was pre-filled with the
  previous answer)

Verified e2e: myTracks emitted on submit + reconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-12 00:16:27 +02:00
parent cfc5339a25
commit 6362b97508
10 changed files with 88 additions and 23 deletions

View file

@ -52,6 +52,15 @@ export function registerRoomHandlers(
io.to(room.code).emit("room:state", rooms.toSnapshot(room)) io.to(room.code).emit("room:state", rooms.toSnapshot(room))
} }
// Renvoie au joueur SES propres titres (source de vérité, anti-désync client).
const emitMyTracks = (room: ServerRoom, playerId: string) => {
socket.emit("blindtest:myTracks", {
tracks: room.blindtestTracks
.filter((t) => t.submittedBy === playerId)
.map((t) => ({ trackId: t.id, title: t.title, youtubeId: t.youtubeId })),
})
}
socket.on("room:create", (payload, ack) => { socket.on("room:create", (payload, ack) => {
// Le pseudo est choisi dans la room (player:setName) : on crée sans nom. // Le pseudo est choisi dans la room (player:setName) : on crée sans nom.
const name = cleanName(payload?.playerName) ?? "" const name = cleanName(payload?.playerName) ?? ""
@ -111,6 +120,7 @@ export function registerRoomHandlers(
}) })
io.to(room.code).emit("player:rejoined", { playerId: player.id }) io.to(room.code).emit("player:rejoined", { playerId: player.id })
broadcastState(room) broadcastState(room)
emitMyTracks(room, player.id)
// Re-pousse la manche en cours au socket reconnecté (si partie en cours). // Re-pousse la manche en cours au socket reconnecté (si partie en cours).
room.game?.resync(socket.id, player.id) room.game?.resync(socket.id, player.id)
}) })
@ -293,6 +303,7 @@ export function registerRoomHandlers(
title: meta.title, title: meta.title,
count: mine.length + 1, count: mine.length + 1,
}) })
emitMyTracks(room, playerId)
broadcastState(room) broadcastState(room)
}) })
@ -311,6 +322,7 @@ export function registerRoomHandlers(
const removed = room.blindtestTracks.length < before const removed = room.blindtestTracks.length < before
ack({ ok: removed }) ack({ ok: removed })
if (removed) { if (removed) {
emitMyTracks(room, playerId)
broadcastState(room) broadcastState(room)
} }
}) })

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { Disc3, Pause, Play, Rewind } from "lucide-react" import { Disc3, Pause, Play, Rewind, Volume2 } from "lucide-react"
import type { import type {
BlindtestAnswer, BlindtestAnswer,
BlindtestMode, BlindtestMode,
@ -42,6 +42,10 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
const showReveal = truth !== null const showReveal = truth !== null
// Les navigateurs bloquent la lecture audible sans geste utilisateur : le non-DJ
// doit "armer" le son une fois (le DJ a déjà cliqué Play, donc dispensé).
const [audioArmed, setAudioArmed] = useState(false)
// Non-DJ : on suit le DJ via media:sync (avec compensation de latence). // Non-DJ : on suit le DJ via media:sync (avec compensation de latence).
useEffect(() => { useEffect(() => {
if (isDj || !ready || !mediaSync) { if (isDj || !ready || !mediaSync) {
@ -50,14 +54,25 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
if (mediaSync.action === "play") { if (mediaSync.action === "play") {
api.seek(mediaSync.positionSec + Math.max(0, elapsed)) api.seek(mediaSync.positionSec + Math.max(0, elapsed))
api.play() if (audioArmed) {
api.play()
}
} else if (mediaSync.action === "pause") { } else if (mediaSync.action === "pause") {
api.seek(mediaSync.positionSec) api.seek(mediaSync.positionSec)
api.pause() api.pause()
} else { } else {
api.seek(mediaSync.positionSec) api.seek(mediaSync.positionSec)
} }
}, [mediaSync, isDj, ready, api]) }, [mediaSync, isDj, ready, api, audioArmed])
function armAudio() {
setAudioArmed(true)
if (mediaSync && mediaSync.action !== "pause") {
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
}
api.play()
}
// Au reveal, on coupe le son. // Au reveal, on coupe le son.
useEffect(() => { useEffect(() => {
@ -98,6 +113,16 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
</motion.div> </motion.div>
</div> </div>
)} )}
{/* Non-DJ : geste requis pour débloquer l'audio (autoplay policy). */}
{!showReveal && !isDj && !audioArmed && (
<button
onClick={armAudio}
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-black/60 text-white backdrop-blur-sm"
>
<Volume2 className="size-10" />
<span className="font-heading font-bold">{t.blindtest.enableSound}</span>
</button>
)}
</div> </div>
{isDj && !showReveal && ( {isDj && !showReveal && (

View file

@ -550,12 +550,6 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
) )
} }
interface MyTrack {
trackId: string
title: string
youtubeId: string
}
function TrackSubmission({ function TrackSubmission({
tracksPerPlayer, tracksPerPlayer,
myCount, myCount,
@ -565,11 +559,12 @@ function TrackSubmission({
}) { }) {
const submitTrack = useRoomStore((s) => s.submitTrack) const submitTrack = useRoomStore((s) => s.submitTrack)
const removeTrack = useRoomStore((s) => s.removeTrack) const removeTrack = useRoomStore((s) => s.removeTrack)
// Source de vérité : le serveur renvoie nos titres (anti-désync au remount).
const myTracks = useRoomStore((s) => s.myTracks)
const { t } = useI18n() const { t } = useI18n()
const [url, setUrl] = useState("") const [url, setUrl] = useState("")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null) const [feedback, setFeedback] = useState<string | null>(null)
const [tracks, setTracks] = useState<MyTrack[]>([])
const quotaReached = myCount >= tracksPerPlayer const quotaReached = myCount >= tracksPerPlayer
async function submit() { async function submit() {
@ -579,11 +574,8 @@ function TrackSubmission({
setSubmitting(true) setSubmitting(true)
setFeedback(null) setFeedback(null)
const res = await submitTrack(url.trim()) const res = await submitTrack(url.trim())
if (res.accepted && res.trackId && res.youtubeId) { if (res.accepted) {
setTracks((t) => [ // La liste se met à jour via l'event serveur blindtest:myTracks.
...t,
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
])
setUrl("") setUrl("")
} else { } else {
setFeedback(res.reason ?? t.lobby.rejected) setFeedback(res.reason ?? t.lobby.rejected)
@ -592,10 +584,7 @@ function TrackSubmission({
} }
async function remove(trackId: string) { async function remove(trackId: string) {
const ok = await removeTrack(trackId) await removeTrack(trackId)
if (ok) {
setTracks((t) => t.filter((x) => x.trackId !== trackId))
}
} }
return ( return (
@ -604,9 +593,9 @@ function TrackSubmission({
{t.lobby.myTracks(myCount, tracksPerPlayer)} {t.lobby.myTracks(myCount, tracksPerPlayer)}
</span> </span>
{tracks.length > 0 && ( {myTracks.length > 0 && (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{tracks.map((track) => ( {myTracks.map((track) => (
<li <li
key={track.trackId} key={track.trackId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2" className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"

View file

@ -92,7 +92,12 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
)} )}
{isText ? ( {isText ? (
<FreeAnswer disabled={showReveal || hasVoted} onSubmit={voteText} t={t} /> <FreeAnswer
key={round.endsAt}
disabled={showReveal || hasVoted}
onSubmit={voteText}
t={t}
/>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{(question.choices ?? []).map((choice, index) => ( {(question.choices ?? []).map((choice, index) => (

View file

@ -109,6 +109,7 @@ export const en: Dict = {
validate: "Submit", validate: "Submit",
}, },
blindtest: { blindtest: {
enableSound: "Enable sound",
djHint: "You're the DJ 🎧 — control playback (and vote like everyone)", djHint: "You're the DJ 🎧 — control playback (and vote like everyone)",
play: "Play", play: "Play",
pause: "Pause", pause: "Pause",

View file

@ -107,6 +107,7 @@ export const fr = {
validate: "Valider", validate: "Valider",
}, },
blindtest: { blindtest: {
enableSound: "Activer le son",
djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)", djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)",
play: "Play", play: "Play",
pause: "Pause", pause: "Pause",

View file

@ -65,6 +65,10 @@ export function useYoutube(youtubeId: string) {
modestbranding: 1, modestbranding: 1,
rel: 0, rel: 0,
playsinline: 1, playsinline: 1,
// Évite l'avertissement postMessage "target origin does not match"
// et fiabilise la transmission des commandes au lecteur.
enablejsapi: 1,
origin: window.location.origin,
}, },
events: { events: {
onReady: () => { onReady: () => {

View file

@ -129,7 +129,15 @@ export function RoomPage({ code }: { code: string }) {
<PlayerCards <PlayerCards
snapshot={snapshot} snapshot={snapshot}
playerId={playerId} playerId={playerId}
showVoteStatus={snapshot.status === "in_round"} // En blindtest "qui l'a ajouté" (et mixte), on cache l'état de
// vote : le contributeur ne vote jamais → sinon il est repérable.
showVoteStatus={
snapshot.status === "in_round" &&
!(
round?.type === "blindtest" &&
snapshot.settings.blindtestMode !== "title_artist"
)
}
votedIds={voteProgress?.voted} votedIds={voteProgress?.voted}
/> />
{round?.type === "blindtest" ? ( {round?.type === "blindtest" ? (

View file

@ -6,6 +6,7 @@ import {
type ErrorPayload, type ErrorPayload,
type MediaControlPayload, type MediaControlPayload,
type MediaSyncPayload, type MediaSyncPayload,
type MyTrackInfo,
type PlayerScore, type PlayerScore,
type RoomCreatedPayload, type RoomCreatedPayload,
type RoomSnapshot, type RoomSnapshot,
@ -51,6 +52,8 @@ interface RoomState {
pseudoSet: boolean pseudoSet: boolean
snapshot: RoomSnapshot | null snapshot: RoomSnapshot | null
lastError: ErrorPayload | null lastError: ErrorPayload | null
/** Mes titres blindtest (renvoyés par le serveur, source de vérité). */
myTracks: MyTrackInfo[]
// État de jeu (poussé par le moteur serveur). // État de jeu (poussé par le moteur serveur).
round: ActiveRound | null round: ActiveRound | null
@ -96,6 +99,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
pseudoSet: !!saved?.name, pseudoSet: !!saved?.name,
snapshot: null, snapshot: null,
lastError: null, lastError: null,
myTracks: [],
round: null, round: null,
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
@ -242,6 +246,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
pseudoSet: false, pseudoSet: false,
snapshot: null, snapshot: null,
lastError: null, lastError: null,
myTracks: [],
round: null, round: null,
voteProgress: null, voteProgress: null,
reveal: null, reveal: null,
@ -303,6 +308,9 @@ socket.on("room:state", (snapshot) =>
) )
) )
socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("blindtest:myTracks", ({ tracks }) =>
useRoomStore.setState({ myTracks: tracks })
)
socket.on("round:start", (payload) => socket.on("round:start", (payload) =>
useRoomStore.setState((s) => { useRoomStore.setState((s) => {

View file

@ -78,6 +78,17 @@ export interface RemoveTrackPayload {
trackId: string trackId: string
} }
/** Un titre soumis par le joueur lui-même (renvoyé en privé, source de vérité). */
export interface MyTrackInfo {
trackId: string
title: string
youtubeId: string
}
export interface MyTracksPayload {
tracks: MyTrackInfo[]
}
export interface RoundStartPayload { export interface RoundStartPayload {
type: RoundType type: RoundType
djId?: string djId?: string
@ -175,6 +186,7 @@ export interface ServerToClientEvents {
"player:left": (payload: PlayerRefPayload) => void "player:left": (payload: PlayerRefPayload) => void
"player:rejoined": (payload: PlayerRefPayload) => void "player:rejoined": (payload: PlayerRefPayload) => void
"blindtest:submitOk": (payload: SubmitOkPayload) => void "blindtest:submitOk": (payload: SubmitOkPayload) => void
"blindtest:myTracks": (payload: MyTracksPayload) => void
"round:start": (payload: RoundStartPayload) => void "round:start": (payload: RoundStartPayload) => void
"media:sync": (payload: MediaSyncPayload) => void "media:sync": (payload: MediaSyncPayload) => void
"round:voteAck": (payload: VoteAckPayload) => void "round:voteAck": (payload: VoteAckPayload) => void