From 6362b9750855fc03e8c2093c6433c5df6963901e Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Fri, 12 Jun 2026 00:16:27 +0200 Subject: [PATCH] fix(web): blindtest sound, hidden vote status, authoritative tracks, quiz input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/server/src/handlers.ts | 12 +++++++++ apps/web/src/components/blindtest-view.tsx | 31 +++++++++++++++++++--- apps/web/src/components/lobby-view.tsx | 25 +++++------------ apps/web/src/components/quiz-view.tsx | 7 ++++- apps/web/src/i18n/en.ts | 1 + apps/web/src/i18n/fr.ts | 1 + apps/web/src/lib/youtube.ts | 4 +++ apps/web/src/pages/room.tsx | 10 ++++++- apps/web/src/store/room.ts | 8 ++++++ packages/shared/src/events.ts | 12 +++++++++ 10 files changed, 88 insertions(+), 23 deletions(-) diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index da2fe57..d39772f 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -52,6 +52,15 @@ export function registerRoomHandlers( 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) => { // Le pseudo est choisi dans la room (player:setName) : on crée sans nom. const name = cleanName(payload?.playerName) ?? "" @@ -111,6 +120,7 @@ export function registerRoomHandlers( }) io.to(room.code).emit("player:rejoined", { playerId: player.id }) broadcastState(room) + emitMyTracks(room, player.id) // Re-pousse la manche en cours au socket reconnecté (si partie en cours). room.game?.resync(socket.id, player.id) }) @@ -293,6 +303,7 @@ export function registerRoomHandlers( title: meta.title, count: mine.length + 1, }) + emitMyTracks(room, playerId) broadcastState(room) }) @@ -311,6 +322,7 @@ export function registerRoomHandlers( const removed = room.blindtestTracks.length < before ack({ ok: removed }) if (removed) { + emitMyTracks(room, playerId) broadcastState(room) } }) diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index af3eacf..2b1dac3 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react" import { motion } from "framer-motion" -import { Disc3, Pause, Play, Rewind } from "lucide-react" +import { Disc3, Pause, Play, Rewind, Volume2 } from "lucide-react" import type { BlindtestAnswer, BlindtestMode, @@ -42,6 +42,10 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : 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). useEffect(() => { if (isDj || !ready || !mediaSync) { @@ -50,14 +54,25 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { const elapsed = (Date.now() - mediaSync.atServerTs) / 1000 if (mediaSync.action === "play") { api.seek(mediaSync.positionSec + Math.max(0, elapsed)) - api.play() + if (audioArmed) { + api.play() + } } else if (mediaSync.action === "pause") { api.seek(mediaSync.positionSec) api.pause() } else { 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. useEffect(() => { @@ -98,6 +113,16 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { )} + {/* Non-DJ : geste requis pour débloquer l'audio (autoplay policy). */} + {!showReveal && !isDj && !audioArmed && ( + + )} {isDj && !showReveal && ( diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index fc6a6c6..d7b83d6 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -550,12 +550,6 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { ) } -interface MyTrack { - trackId: string - title: string - youtubeId: string -} - function TrackSubmission({ tracksPerPlayer, myCount, @@ -565,11 +559,12 @@ function TrackSubmission({ }) { const submitTrack = useRoomStore((s) => s.submitTrack) 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 [url, setUrl] = useState("") const [submitting, setSubmitting] = useState(false) const [feedback, setFeedback] = useState(null) - const [tracks, setTracks] = useState([]) const quotaReached = myCount >= tracksPerPlayer async function submit() { @@ -579,11 +574,8 @@ function TrackSubmission({ setSubmitting(true) setFeedback(null) const res = await submitTrack(url.trim()) - if (res.accepted && res.trackId && res.youtubeId) { - setTracks((t) => [ - ...t, - { trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! }, - ]) + if (res.accepted) { + // La liste se met à jour via l'event serveur blindtest:myTracks. setUrl("") } else { setFeedback(res.reason ?? t.lobby.rejected) @@ -592,10 +584,7 @@ function TrackSubmission({ } async function remove(trackId: string) { - const ok = await removeTrack(trackId) - if (ok) { - setTracks((t) => t.filter((x) => x.trackId !== trackId)) - } + await removeTrack(trackId) } return ( @@ -604,9 +593,9 @@ function TrackSubmission({ {t.lobby.myTracks(myCount, tracksPerPlayer)} - {tracks.length > 0 && ( + {myTracks.length > 0 && (
    - {tracks.map((track) => ( + {myTracks.map((track) => (
  • + ) : (
    {(question.choices ?? []).map((choice, index) => ( diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index 785fbaa..f0da3a4 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -109,6 +109,7 @@ export const en: Dict = { validate: "Submit", }, blindtest: { + enableSound: "Enable sound", djHint: "You're the DJ 🎧 — control playback (and vote like everyone)", play: "Play", pause: "Pause", diff --git a/apps/web/src/i18n/fr.ts b/apps/web/src/i18n/fr.ts index ce0220f..6b85d83 100644 --- a/apps/web/src/i18n/fr.ts +++ b/apps/web/src/i18n/fr.ts @@ -107,6 +107,7 @@ export const fr = { validate: "Valider", }, blindtest: { + enableSound: "Activer le son", djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)", play: "Play", pause: "Pause", diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts index 13f47a0..945fd53 100644 --- a/apps/web/src/lib/youtube.ts +++ b/apps/web/src/lib/youtube.ts @@ -65,6 +65,10 @@ export function useYoutube(youtubeId: string) { modestbranding: 1, rel: 0, 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: { onReady: () => { diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 6e88854..ecdab75 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -129,7 +129,15 @@ export function RoomPage({ code }: { code: string }) { {round?.type === "blindtest" ? ( diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 241afc0..fb76f6e 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -6,6 +6,7 @@ import { type ErrorPayload, type MediaControlPayload, type MediaSyncPayload, + type MyTrackInfo, type PlayerScore, type RoomCreatedPayload, type RoomSnapshot, @@ -51,6 +52,8 @@ interface RoomState { pseudoSet: boolean snapshot: RoomSnapshot | 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). round: ActiveRound | null @@ -96,6 +99,7 @@ export const useRoomStore = create((set, get) => ({ pseudoSet: !!saved?.name, snapshot: null, lastError: null, + myTracks: [], round: null, voteProgress: null, reveal: null, @@ -242,6 +246,7 @@ export const useRoomStore = create((set, get) => ({ pseudoSet: false, snapshot: null, lastError: null, + myTracks: [], round: null, voteProgress: null, reveal: null, @@ -303,6 +308,9 @@ socket.on("room:state", (snapshot) => ) ) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) +socket.on("blindtest:myTracks", ({ tracks }) => + useRoomStore.setState({ myTracks: tracks }) +) socket.on("round:start", (payload) => useRoomStore.setState((s) => { diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index fb5090b..cd2daf6 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -78,6 +78,17 @@ export interface RemoveTrackPayload { 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 { type: RoundType djId?: string @@ -175,6 +186,7 @@ export interface ServerToClientEvents { "player:left": (payload: PlayerRefPayload) => void "player:rejoined": (payload: PlayerRefPayload) => void "blindtest:submitOk": (payload: SubmitOkPayload) => void + "blindtest:myTracks": (payload: MyTracksPayload) => void "round:start": (payload: RoundStartPayload) => void "media:sync": (payload: MediaSyncPayload) => void "round:voteAck": (payload: VoteAckPayload) => void