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 && (