fix/playtest-feedback #19
15 changed files with 165 additions and 13 deletions
|
|
@ -44,7 +44,7 @@ describe("match", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("BlindtestRound", () => {
|
describe("BlindtestRound", () => {
|
||||||
test("start : le DJ est neutre (jamais le contributeur)", () => {
|
test("start : le DJ est un humain connecté (le contributeur peut tomber DJ)", () => {
|
||||||
const rooms = new RoomManager()
|
const rooms = new RoomManager()
|
||||||
const { room, player: a } = rooms.create("Alice", "sa")
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||||
|
|
@ -53,10 +53,24 @@ describe("BlindtestRound", () => {
|
||||||
|
|
||||||
const round = new BlindtestRound()
|
const round = new BlindtestRound()
|
||||||
const start = round.start(room)
|
const start = round.start(room)
|
||||||
expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice)
|
// N'importe quel humain (Alice incluse), jamais un bot ni null.
|
||||||
|
expect([a.id, b.id]).toContain(start.djId!)
|
||||||
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
|
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("start : un bot n'est jamais DJ", () => {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
rooms.addBot(room)
|
||||||
|
rooms.addBot(room)
|
||||||
|
room.blindtestTracks = [track(a.id)]
|
||||||
|
prepareBlindtestForRoom(room)
|
||||||
|
|
||||||
|
const round = new BlindtestRound()
|
||||||
|
// Alice est la seule humaine → DJ forcément Alice (les bots sont exclus).
|
||||||
|
expect(round.start(room).djId).toBe(a.id)
|
||||||
|
})
|
||||||
|
|
||||||
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
|
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
|
||||||
const rooms = new RoomManager()
|
const rooms = new RoomManager()
|
||||||
const { room, player: a } = rooms.create("Alice", "sa")
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,14 @@ interface BlindtestRoundData {
|
||||||
track: BlindtestTrack
|
track: BlindtestTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tire un DJ neutre : un humain connecté qui n'a pas soumis ce titre. */
|
// Le blindtest dure plus longtemps (écoute + saisie).
|
||||||
function pickDj(room: ServerRoom, submittedBy: string): string | null {
|
const BLINDTEST_DURATION_SEC = 90
|
||||||
|
|
||||||
|
/** Tire un DJ : n'importe quel humain connecté (y compris le contributeur, pour
|
||||||
|
* ne pas donner d'« immunité » à celui qui n'est jamais tiré). */
|
||||||
|
function pickDj(room: ServerRoom): string | null {
|
||||||
const eligible = [...room.players.values()].filter(
|
const eligible = [...room.players.values()].filter(
|
||||||
(p) => p.connected && !p.isBot && p.id !== submittedBy
|
(p) => p.connected && !p.isBot
|
||||||
)
|
)
|
||||||
if (eligible.length === 0) {
|
if (eligible.length === 0) {
|
||||||
return null
|
return null
|
||||||
|
|
@ -77,13 +81,14 @@ export class BlindtestRound implements GameRound {
|
||||||
|
|
||||||
start(room: ServerRoom): RoundStart {
|
start(room: ServerRoom): RoundStart {
|
||||||
const track = takeTrack(room)
|
const track = takeTrack(room)
|
||||||
// DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle.
|
// DJ = n'importe quel humain (le contributeur peut tomber DJ).
|
||||||
const djId = track ? pickDj(room, track.submittedBy) : null
|
const djId = track ? pickDj(room) : null
|
||||||
const payload: BlindtestRoundPayload | null = track
|
const payload: BlindtestRoundPayload | null = track
|
||||||
? { trackId: track.id, youtubeId: track.youtubeId }
|
? { trackId: track.id, youtubeId: track.youtubeId }
|
||||||
: null
|
: null
|
||||||
return {
|
return {
|
||||||
djId,
|
djId,
|
||||||
|
durationSec: BLINDTEST_DURATION_SEC,
|
||||||
payload,
|
payload,
|
||||||
secretPlayerId: track?.submittedBy,
|
secretPlayerId: track?.submittedBy,
|
||||||
data: track ? { track } : null,
|
data: track ? { track } : null,
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ export function registerRoomHandlers(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchro d'horloge : le client mesure l'offset avec l'horloge serveur.
|
||||||
|
socket.on("time:sync", (ack) => ack(Date.now()))
|
||||||
|
|
||||||
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) ?? ""
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { useYoutube, type YoutubeApi } from "@/lib/youtube"
|
||||||
import { Countdown } from "@/components/countdown"
|
import { Countdown } from "@/components/countdown"
|
||||||
import { Avatar } from "@/components/avatar"
|
import { Avatar } from "@/components/avatar"
|
||||||
import { useI18n } from "@/i18n/context"
|
import { useI18n } from "@/i18n/context"
|
||||||
|
import { serverNow } from "@/lib/clock"
|
||||||
|
|
||||||
const inputClass =
|
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"
|
"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"
|
||||||
|
|
@ -46,12 +47,31 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
// doit "armer" le son une fois (le DJ a déjà cliqué Play, donc dispensé).
|
// doit "armer" le son une fois (le DJ a déjà cliqué Play, donc dispensé).
|
||||||
const [audioArmed, setAudioArmed] = useState(false)
|
const [audioArmed, setAudioArmed] = useState(false)
|
||||||
|
|
||||||
|
// Volume local (par joueur), mémorisé entre les manches/parties.
|
||||||
|
const [volume, setVolume] = useState(() => {
|
||||||
|
const v = Number(localStorage.getItem("nerdware-volume"))
|
||||||
|
return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 100
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) {
|
||||||
|
api.setVolume(volume)
|
||||||
|
}
|
||||||
|
}, [ready, volume, api])
|
||||||
|
function changeVolume(v: number) {
|
||||||
|
setVolume(v)
|
||||||
|
try {
|
||||||
|
localStorage.setItem("nerdware-volume", String(v))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
|
const elapsed = (serverNow() - 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))
|
||||||
if (audioArmed) {
|
if (audioArmed) {
|
||||||
|
|
@ -68,7 +88,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
function armAudio() {
|
function armAudio() {
|
||||||
setAudioArmed(true)
|
setAudioArmed(true)
|
||||||
if (mediaSync && mediaSync.action !== "pause") {
|
if (mediaSync && mediaSync.action !== "pause") {
|
||||||
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
|
const elapsed = (serverNow() - mediaSync.atServerTs) / 1000
|
||||||
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
|
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
|
||||||
}
|
}
|
||||||
api.play()
|
api.play()
|
||||||
|
|
@ -125,6 +145,24 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!showReveal && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => changeVolume(Number(e.target.value))}
|
||||||
|
className="accent-primary flex-1"
|
||||||
|
aria-label={t.blindtest.volume}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground w-8 text-right text-xs tabular-nums">
|
||||||
|
{volume}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isDj && !showReveal && (
|
{isDj && !showReveal && (
|
||||||
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
|
import { serverNow } from "@/lib/clock"
|
||||||
|
|
||||||
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
|
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Secondes restantes. Avant `startsAt` (phase de préparation / transition),
|
* Secondes restantes, basées sur l'horloge serveur (serverNow) pour que tous les
|
||||||
* on affiche la durée pleine sans décompter.
|
* clients affichent le même décompte. Avant `startsAt` (préparation), on affiche
|
||||||
|
* la durée pleine sans décompter.
|
||||||
*/
|
*/
|
||||||
function secondsLeft(startsAt: number, endsAt: number): number {
|
function secondsLeft(startsAt: number, endsAt: number): number {
|
||||||
const now = Date.now()
|
const now = serverNow()
|
||||||
if (now < startsAt) {
|
if (now < startsAt) {
|
||||||
return Math.ceil((endsAt - startsAt) / 1000)
|
return Math.ceil((endsAt - startsAt) / 1000)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
apps/web/src/components/theme-toggle.tsx
Normal file
31
apps/web/src/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react"
|
||||||
|
import { useTheme } from "@/components/theme-provider"
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ value: "light", Icon: Sun },
|
||||||
|
{ value: "dark", Icon: Moon },
|
||||||
|
{ value: "system", Icon: Monitor },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Sélecteur de thème : clair / sombre / système. */
|
||||||
|
export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-1 ${className}`}>
|
||||||
|
{OPTIONS.map(({ value, Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setTheme(value)}
|
||||||
|
title={value}
|
||||||
|
className={`rounded-md p-1.5 transition-colors ${
|
||||||
|
theme === value
|
||||||
|
? "bg-primary/10 text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -110,6 +110,7 @@ export const en: Dict = {
|
||||||
},
|
},
|
||||||
blindtest: {
|
blindtest: {
|
||||||
enableSound: "Enable sound",
|
enableSound: "Enable sound",
|
||||||
|
volume: "Volume",
|
||||||
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",
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export const fr = {
|
||||||
},
|
},
|
||||||
blindtest: {
|
blindtest: {
|
||||||
enableSound: "Activer le son",
|
enableSound: "Activer le son",
|
||||||
|
volume: "Volume",
|
||||||
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",
|
||||||
|
|
|
||||||
45
apps/web/src/lib/clock.ts
Normal file
45
apps/web/src/lib/clock.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Synchro d'horloge avec le serveur : on estime l'offset (horloge serveur −
|
||||||
|
// horloge locale) pour que tous les clients affichent le même décompte, quel que
|
||||||
|
// soit le décalage d'horloge de chaque machine.
|
||||||
|
|
||||||
|
import { socket } from "./socket"
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
/** Instant courant aligné sur l'horloge serveur. */
|
||||||
|
export function serverNow(): number {
|
||||||
|
return Date.now() + offset
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une mesure : renvoie [rtt, offset estimé]. */
|
||||||
|
async function syncOnce(): Promise<[number, number]> {
|
||||||
|
const t0 = Date.now()
|
||||||
|
const serverTime = await new Promise<number>((resolve) =>
|
||||||
|
socket.emit("time:sync", resolve)
|
||||||
|
)
|
||||||
|
const t1 = Date.now()
|
||||||
|
const rtt = t1 - t0
|
||||||
|
// Au moment de la réponse (t1), l'horloge serveur valait ~ serverTime + rtt/2.
|
||||||
|
return [rtt, serverTime + rtt / 2 - t1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plusieurs mesures : on garde celle de plus faible latence (plus fiable). */
|
||||||
|
async function sync(): Promise<void> {
|
||||||
|
let bestRtt = Infinity
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
try {
|
||||||
|
const [rtt, est] = await syncOnce()
|
||||||
|
if (rtt < bestRtt) {
|
||||||
|
bestRtt = rtt
|
||||||
|
offset = est
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on("connect", () => void sync())
|
||||||
|
if (socket.connected) {
|
||||||
|
void sync()
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,8 @@ export interface YoutubeApi {
|
||||||
seek: (seconds: number) => void
|
seek: (seconds: number) => void
|
||||||
time: () => number
|
time: () => number
|
||||||
duration: () => number
|
duration: () => number
|
||||||
|
/** Volume 0–100. */
|
||||||
|
setVolume: (v: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -95,6 +97,7 @@ export function useYoutube(youtubeId: string) {
|
||||||
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
|
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
|
||||||
time: () => playerRef.current?.getCurrentTime() ?? 0,
|
time: () => playerRef.current?.getCurrentTime() ?? 0,
|
||||||
duration: () => playerRef.current?.getDuration() ?? 0,
|
duration: () => playerRef.current?.getDuration() ?? 0,
|
||||||
|
setVolume: (v) => playerRef.current?.setVolume(v),
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useRoomStore } from "@/store/room"
|
||||||
import { useI18n } from "@/i18n/context"
|
import { useI18n } from "@/i18n/context"
|
||||||
import { errorMessage } from "@/i18n/error"
|
import { errorMessage } from "@/i18n/error"
|
||||||
import { LanguageToggle } from "@/i18n/language-toggle"
|
import { LanguageToggle } from "@/i18n/language-toggle"
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||||
|
|
@ -60,7 +61,10 @@ export function HomePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
|
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
|
||||||
<LanguageToggle className="absolute top-4 right-4 z-10" />
|
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<LanguageToggle />
|
||||||
|
</div>
|
||||||
{/* Halo animé */}
|
{/* Halo animé */}
|
||||||
<motion.div
|
<motion.div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { RoundTransition } from "@/components/round-transition"
|
||||||
import { PseudoScreen } from "@/components/pseudo-screen"
|
import { PseudoScreen } from "@/components/pseudo-screen"
|
||||||
import { useI18n } from "@/i18n/context"
|
import { useI18n } from "@/i18n/context"
|
||||||
import { LanguageToggle } from "@/i18n/language-toggle"
|
import { LanguageToggle } from "@/i18n/language-toggle"
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
|
|
||||||
export function RoomPage({ code }: { code: string }) {
|
export function RoomPage({ code }: { code: string }) {
|
||||||
const [, navigate] = useLocation()
|
const [, navigate] = useLocation()
|
||||||
|
|
@ -100,6 +101,7 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
{connected ? t.room.connected : t.room.disconnected}
|
{connected ? t.room.connected : t.room.disconnected}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<ThemeToggle />
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
type VoteAckPayload,
|
type VoteAckPayload,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import { socket } from "@/lib/socket"
|
import { socket } from "@/lib/socket"
|
||||||
|
import "@/lib/clock" // démarre la synchro d'horloge avec le serveur
|
||||||
import {
|
import {
|
||||||
clearSession,
|
clearSession,
|
||||||
getSession,
|
getSession,
|
||||||
|
|
|
||||||
1
apps/web/src/types/youtube.d.ts
vendored
1
apps/web/src/types/youtube.d.ts
vendored
|
|
@ -24,6 +24,7 @@ declare global {
|
||||||
getCurrentTime(): number
|
getCurrentTime(): number
|
||||||
getDuration(): number
|
getDuration(): number
|
||||||
getPlayerState(): number
|
getPlayerState(): number
|
||||||
|
setVolume(volume: number): void
|
||||||
loadVideoById(id: string): void
|
loadVideoById(id: string): void
|
||||||
destroy(): void
|
destroy(): void
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ export type Ack<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorP
|
||||||
|
|
||||||
/** CLIENT → SERVER */
|
/** CLIENT → SERVER */
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
|
"time:sync": (ack: (serverNow: number) => void) => void
|
||||||
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue