fix/playtest-feedback #19
10 changed files with 88 additions and 23 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
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 }) {
|
|||
</motion.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>
|
||||
|
||||
{isDj && !showReveal && (
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const [tracks, setTracks] = useState<MyTrack[]>([])
|
||||
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)}
|
||||
</span>
|
||||
|
||||
{tracks.length > 0 && (
|
||||
{myTracks.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tracks.map((track) => (
|
||||
{myTracks.map((track) => (
|
||||
<li
|
||||
key={track.trackId}
|
||||
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
|
||||
|
|
|
|||
|
|
@ -92,7 +92,12 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
)}
|
||||
|
||||
{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">
|
||||
{(question.choices ?? []).map((choice, index) => (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,15 @@ export function RoomPage({ code }: { code: string }) {
|
|||
<PlayerCards
|
||||
snapshot={snapshot}
|
||||
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}
|
||||
/>
|
||||
{round?.type === "blindtest" ? (
|
||||
|
|
|
|||
|
|
@ -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<RoomState>((set, get) => ({
|
|||
pseudoSet: !!saved?.name,
|
||||
snapshot: null,
|
||||
lastError: null,
|
||||
myTracks: [],
|
||||
round: null,
|
||||
voteProgress: null,
|
||||
reveal: null,
|
||||
|
|
@ -242,6 +246,7 @@ export const useRoomStore = create<RoomState>((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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue