feature/blindtest #6
7 changed files with 255 additions and 105 deletions
|
|
@ -127,13 +127,26 @@ export class GameEngine implements RoomGameController {
|
|||
}
|
||||
this.room.status = "in_round"
|
||||
this.broadcastState()
|
||||
this.io.to(this.room.code).emit("round:start", {
|
||||
// L'identité du DJ (= contributeur du titre) est privée : seul le DJ la
|
||||
// reçoit, sinon "qui l'a ajouté" deviendrait trivial.
|
||||
const base = {
|
||||
type: round.type,
|
||||
djId: this.runtime.djId ?? undefined,
|
||||
startsAt: startedAt,
|
||||
endsAt,
|
||||
payload: start.payload,
|
||||
})
|
||||
}
|
||||
const djSocketId = this.runtime.djId
|
||||
? this.room.players.get(this.runtime.djId)?.socketId
|
||||
: null
|
||||
if (djSocketId) {
|
||||
this.io.to(djSocketId).emit("round:start", {
|
||||
...base,
|
||||
djId: this.runtime.djId ?? undefined,
|
||||
})
|
||||
this.io.to(this.room.code).except(djSocketId).emit("round:start", base)
|
||||
} else {
|
||||
this.io.to(this.room.code).emit("round:start", base)
|
||||
}
|
||||
|
||||
// Attend la première condition de fin : timer écoulé OU tous votés.
|
||||
await new Promise<void>((resolve) => {
|
||||
|
|
@ -177,10 +190,11 @@ export class GameEngine implements RoomGameController {
|
|||
}
|
||||
}
|
||||
|
||||
/** Votants éligibles : tous les joueurs connectés (le DJ inclus). */
|
||||
/** Votants éligibles : joueurs connectés, hors DJ (= contributeur, ne vote pas). */
|
||||
private eligibleVoters(): string[] {
|
||||
const djId = this.runtime?.djId
|
||||
return [...this.room.players.values()]
|
||||
.filter((p) => p.connected)
|
||||
.filter((p) => p.connected && p.id !== djId)
|
||||
.map((p) => p.id)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,19 +44,37 @@ describe("match", () => {
|
|||
})
|
||||
|
||||
describe("BlindtestRound", () => {
|
||||
test("start tire un DJ neutre (jamais le contributeur)", () => {
|
||||
test("start : le DJ est le contributeur du titre", () => {
|
||||
const rooms = new RoomManager()
|
||||
const { room, player: a } = rooms.create("Alice", "sa")
|
||||
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||
rooms.join(room.code, "Bob", "sb")
|
||||
room.blindtestTracks = [track(a.id)]
|
||||
prepareBlindtestForRoom(room)
|
||||
|
||||
const round = new BlindtestRound()
|
||||
const start = round.start(room)
|
||||
expect(start.djId).toBe(b.id) // jamais Alice (contributrice)
|
||||
expect(start.djId).toBe(a.id) // Alice a ajouté le titre → elle est DJ
|
||||
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
|
||||
})
|
||||
|
||||
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
|
||||
const rooms = new RoomManager()
|
||||
const { room, player: a } = rooms.create("Alice", "sa")
|
||||
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||
const { player: c } = rooms.join(room.code, "Carol", "sc")
|
||||
const t = track(a.id) // Alice = contributrice/DJ
|
||||
const round = new BlindtestRound()
|
||||
const ctx = ctxFor(room, t, "who_added")
|
||||
round.submitAnswer(ctx, b.id, { guessedPlayerId: c.id }) // faux
|
||||
round.submitAnswer(ctx, c.id, { guessedPlayerId: a.id }) // juste
|
||||
round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // ignoré (contributrice)
|
||||
const deltas = round.score(ctx)
|
||||
// Carol devine juste → 100 ; Alice trompe Bob (1 mauvaise devinette) → 50
|
||||
expect(deltas).toContainEqual({ playerId: c.id, delta: 100 })
|
||||
expect(deltas).toContainEqual({ playerId: a.id, delta: 50 })
|
||||
expect(deltas.find((d) => d.playerId === b.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("who_added : points si on devine le bon contributeur", () => {
|
||||
const rooms = new RoomManager()
|
||||
const { room, player: a } = rooms.create("Alice", "sa")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Épreuve Blindtest : un titre = une manche. Un DJ neutre (jamais le
|
||||
// contributeur) pilote la lecture ; tout le monde vote à l'aveugle.
|
||||
// Épreuve Blindtest : un titre = une manche. Le contributeur du titre est le DJ
|
||||
// (il pilote la lecture, son identité reste cachée aux autres). Il ne marque pas
|
||||
// de points, SAUF en "qui l'a ajouté" : il gagne quand un joueur le devine mal.
|
||||
|
||||
import type {
|
||||
Answer,
|
||||
|
|
@ -18,24 +19,15 @@ import { takeTrack } from "./pool"
|
|||
const TITLE_POINTS = 60
|
||||
const ARTIST_POINTS = 40
|
||||
const WHO_POINTS = 100
|
||||
/** Points gagnés par le contributeur pour chaque joueur qui le devine mal. */
|
||||
const MISDIRECTION_POINTS = 50
|
||||
|
||||
interface BlindtestRoundData {
|
||||
track: BlindtestTrack
|
||||
}
|
||||
|
||||
/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */
|
||||
function pickDj(room: ServerRoom, submittedBy: string): string | null {
|
||||
const eligible = [...room.players.values()].filter(
|
||||
(p) => p.connected && p.id !== submittedBy
|
||||
)
|
||||
if (eligible.length === 0) {
|
||||
return null
|
||||
}
|
||||
return eligible[Math.floor(Math.random() * eligible.length)].id
|
||||
}
|
||||
|
||||
/** Calcule le résultat d'un vote selon le mode. */
|
||||
function resultFor(
|
||||
/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */
|
||||
function voterResult(
|
||||
mode: BlindtestMode,
|
||||
answer: Answer | undefined,
|
||||
track: BlindtestTrack
|
||||
|
|
@ -68,7 +60,8 @@ export class BlindtestRound implements GameRound {
|
|||
|
||||
start(room: ServerRoom): RoundStart {
|
||||
const track = takeTrack(room)
|
||||
const djId = track ? pickDj(room, track.submittedBy) : null
|
||||
// Le DJ EST le contributeur : il lance/pilote son propre titre.
|
||||
const djId = track ? track.submittedBy : null
|
||||
const payload: BlindtestRoundPayload | null = track
|
||||
? { trackId: track.id, youtubeId: track.youtubeId }
|
||||
: null
|
||||
|
|
@ -76,23 +69,50 @@ export class BlindtestRound implements GameRound {
|
|||
}
|
||||
|
||||
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
|
||||
// Premier vote verrouillé (idempotent).
|
||||
if (ctx.votes.has(playerId)) {
|
||||
const { track } = ctx.data as BlindtestRoundData
|
||||
// Le contributeur (DJ) ne vote pas ; premier vote verrouillé pour les autres.
|
||||
if (playerId === track.submittedBy || ctx.votes.has(playerId)) {
|
||||
return
|
||||
}
|
||||
ctx.votes.set(playerId, answer)
|
||||
}
|
||||
|
||||
/** Résultat par joueur (votants + bonus de tromperie du contributeur). */
|
||||
private buildResults(ctx: RoundContext): BlindtestPerPlayerResult {
|
||||
const { track } = ctx.data as BlindtestRoundData
|
||||
const mode = ctx.room.settings.blindtestMode
|
||||
const perPlayer: BlindtestPerPlayerResult = {}
|
||||
|
||||
let wrongGuessers = 0
|
||||
for (const player of ctx.room.players.values()) {
|
||||
if (player.id === track.submittedBy) {
|
||||
continue
|
||||
}
|
||||
const result = voterResult(mode, ctx.votes.get(player.id), track)
|
||||
perPlayer[player.id] = result
|
||||
if (
|
||||
(mode === "who_added" || mode === "mixed") &&
|
||||
result.guessedCorrect === false
|
||||
) {
|
||||
wrongGuessers++
|
||||
}
|
||||
}
|
||||
|
||||
// Contributeur : pas de points hors who_added/mixed ; sinon tromperie.
|
||||
const misdirection =
|
||||
mode === "who_added" || mode === "mixed"
|
||||
? wrongGuessers * MISDIRECTION_POINTS
|
||||
: 0
|
||||
perPlayer[track.submittedBy] = { points: misdirection }
|
||||
|
||||
return perPlayer
|
||||
}
|
||||
|
||||
reveal(ctx: RoundContext): {
|
||||
truth: BlindtestRevealTruth
|
||||
perPlayerResult: BlindtestPerPlayerResult
|
||||
} {
|
||||
const { track } = ctx.data as BlindtestRoundData
|
||||
const mode = ctx.room.settings.blindtestMode
|
||||
const perPlayerResult: BlindtestPerPlayerResult = {}
|
||||
for (const player of ctx.room.players.values()) {
|
||||
perPlayerResult[player.id] = resultFor(mode, ctx.votes.get(player.id), track)
|
||||
}
|
||||
const submitter = ctx.room.players.get(track.submittedBy)
|
||||
return {
|
||||
truth: {
|
||||
|
|
@ -102,22 +122,16 @@ export class BlindtestRound implements GameRound {
|
|||
submittedBy: track.submittedBy,
|
||||
submittedByName: submitter?.name ?? "?",
|
||||
},
|
||||
perPlayerResult,
|
||||
perPlayerResult: this.buildResults(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
score(ctx: RoundContext): ScoreDelta[] {
|
||||
const { track } = ctx.data as BlindtestRoundData
|
||||
const mode = ctx.room.settings.blindtestMode
|
||||
const perPlayer = this.buildResults(ctx)
|
||||
const deltas: ScoreDelta[] = []
|
||||
for (const [playerId, answer] of ctx.votes) {
|
||||
// Le contributeur ne marque pas sur son propre titre.
|
||||
if (playerId === track.submittedBy) {
|
||||
continue
|
||||
}
|
||||
const { points } = resultFor(mode, answer, track)
|
||||
if (points > 0) {
|
||||
deltas.push({ playerId, delta: points })
|
||||
for (const [playerId, result] of Object.entries(perPlayer)) {
|
||||
if (result.points > 0) {
|
||||
deltas.push({ playerId, delta: result.points })
|
||||
}
|
||||
}
|
||||
return deltas
|
||||
|
|
|
|||
|
|
@ -128,6 +128,18 @@ export function registerRoomHandlers(
|
|||
})
|
||||
return
|
||||
}
|
||||
const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest")
|
||||
const connected = [...room.players.values()].filter((p) => p.connected).length
|
||||
if (hasBlindtest && connected < 3) {
|
||||
ack({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NEED_THREE",
|
||||
message: "Le blindtest nécessite au moins 3 joueurs.",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
ack({ ok: true, data: null })
|
||||
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
|
||||
// Fire-and-forget : la boucle de jeu broadcast son propre état.
|
||||
|
|
|
|||
|
|
@ -11,13 +11,19 @@ import type {
|
|||
} from "@nerdware/shared"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
import { useYoutube } from "@/lib/youtube"
|
||||
import { useYoutube, type YoutubeApi } from "@/lib/youtube"
|
||||
import { Countdown } from "@/components/countdown"
|
||||
import { Avatar } from "@/components/avatar"
|
||||
|
||||
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"
|
||||
|
||||
function fmt(s: number): string {
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const round = useRoomStore((s) => s.round)
|
||||
const reveal = useRoomStore((s) => s.reveal)
|
||||
|
|
@ -58,19 +64,6 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
}
|
||||
}, [showReveal, ready, api])
|
||||
|
||||
function djPlay() {
|
||||
api.play()
|
||||
mediaControl("play", api.time())
|
||||
}
|
||||
function djPause() {
|
||||
api.pause()
|
||||
mediaControl("pause", api.time())
|
||||
}
|
||||
function djRestart() {
|
||||
api.seek(0)
|
||||
mediaControl("seek", 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-5">
|
||||
<header className="flex items-center justify-between">
|
||||
|
|
@ -86,9 +79,13 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
)}
|
||||
</header>
|
||||
|
||||
{/* Pochette : le lecteur YouTube est caché derrière (son seulement). */}
|
||||
<div className="relative mx-auto aspect-square w-48 overflow-hidden rounded-2xl">
|
||||
<div ref={hostRef} className="absolute inset-0" />
|
||||
{/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la
|
||||
manche (son seulement), puis révélé (bannière visible) au reveal. */}
|
||||
<div className="bg-card relative aspect-video w-full overflow-hidden rounded-2xl">
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="absolute inset-0 [&>iframe]:absolute [&>iframe]:inset-0 [&>iframe]:size-full"
|
||||
/>
|
||||
{!showReveal && (
|
||||
<div className="bg-card absolute inset-0 flex items-center justify-center">
|
||||
<motion.div
|
||||
|
|
@ -102,25 +99,10 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</div>
|
||||
|
||||
{isDj && !showReveal && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs uppercase">
|
||||
Tu es le DJ 🎧
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={!ready} onClick={djPlay}>
|
||||
<Play /> Play
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={!ready} onClick={djPause}>
|
||||
<Pause /> Pause
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={!ready} onClick={djRestart}>
|
||||
<Rewind /> Début
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
||||
)}
|
||||
|
||||
{!showReveal && (
|
||||
{!isDj && !showReveal && (
|
||||
<VoteForm
|
||||
mode={snapshot.settings.blindtestMode}
|
||||
snapshot={snapshot}
|
||||
|
|
@ -133,6 +115,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
{showReveal && truth && (
|
||||
<RevealCard
|
||||
truth={truth}
|
||||
isDj={isDj}
|
||||
result={
|
||||
(reveal?.perPlayerResult as BlindtestPerPlayerResult)[playerId ?? ""]
|
||||
}
|
||||
|
|
@ -142,6 +125,92 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
)
|
||||
}
|
||||
|
||||
function DjControls({
|
||||
ready,
|
||||
api,
|
||||
mediaControl,
|
||||
}: {
|
||||
ready: boolean
|
||||
api: YoutubeApi
|
||||
mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void
|
||||
}) {
|
||||
const [pos, setPos] = useState(0)
|
||||
const [dur, setDur] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
return
|
||||
}
|
||||
const id = setInterval(() => {
|
||||
setPos(api.time())
|
||||
setDur(api.duration())
|
||||
}, 400)
|
||||
return () => clearInterval(id)
|
||||
}, [ready, api])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-muted-foreground text-center text-xs uppercase">
|
||||
Tu es le DJ 🎧 — c'est ton titre, fais-le deviner 🤫
|
||||
</span>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
api.play()
|
||||
mediaControl("play", api.time())
|
||||
}}
|
||||
>
|
||||
<Play /> Play
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
api.pause()
|
||||
mediaControl("pause", api.time())
|
||||
}}
|
||||
>
|
||||
<Pause /> Pause
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
api.seek(0)
|
||||
setPos(0)
|
||||
mediaControl("seek", 0)
|
||||
}}
|
||||
>
|
||||
<Rewind /> Début
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs tabular-nums">
|
||||
<span className="text-muted-foreground w-9 text-right">{fmt(pos)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(dur, 1)}
|
||||
step="any"
|
||||
value={Math.min(pos, dur || 0)}
|
||||
disabled={!ready}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
setPos(v)
|
||||
api.seek(v)
|
||||
mediaControl("seek", v)
|
||||
}}
|
||||
className="accent-primary flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground w-9">{fmt(dur)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VoteForm({
|
||||
mode,
|
||||
snapshot,
|
||||
|
|
@ -238,9 +307,11 @@ function VoteForm({
|
|||
|
||||
function RevealCard({
|
||||
truth,
|
||||
isDj,
|
||||
result,
|
||||
}: {
|
||||
truth: BlindtestRevealTruth
|
||||
isDj: boolean
|
||||
result?: BlindtestPerPlayerResult[string]
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -252,11 +323,13 @@ function RevealCard({
|
|||
<Avatar seed={truth.submittedByName} className="size-6" />
|
||||
<span className="font-medium">{truth.submittedByName}</span>
|
||||
</p>
|
||||
{result && (
|
||||
{result && (result.points > 0 || !isDj) && (
|
||||
<p
|
||||
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
{result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"}
|
||||
{result.points > 0
|
||||
? `+${result.points} points ${isDj ? "🤫" : "🎉"}`
|
||||
: "Raté 💥"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
|
|||
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"
|
||||
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
const arr = [...items]
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[arr[i], arr[j]] = [arr[j], arr[i]]
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
/** Construit la séquence de manches selon le type de partie. */
|
||||
function buildRounds(
|
||||
gameType: GameType,
|
||||
|
|
@ -37,21 +46,12 @@ function buildRounds(
|
|||
if (gameType === "blindtest") {
|
||||
return Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
|
||||
}
|
||||
// Mixte : on alterne quiz / blindtest pour multiplier les changements de mode.
|
||||
const rounds: RoundConfig[] = []
|
||||
let q = quizCount
|
||||
let b = totalTracks
|
||||
while (q > 0 || b > 0) {
|
||||
if (q > 0) {
|
||||
rounds.push({ type: "quiz" })
|
||||
q--
|
||||
}
|
||||
if (b > 0) {
|
||||
rounds.push({ type: "blindtest" })
|
||||
b--
|
||||
}
|
||||
}
|
||||
return rounds
|
||||
// Mixte : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite.
|
||||
const rounds: RoundConfig[] = [
|
||||
...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })),
|
||||
...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })),
|
||||
]
|
||||
return shuffle(rounds)
|
||||
}
|
||||
|
||||
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
|
|
@ -71,8 +71,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
const showQuiz = gameType === "quiz" || gameType === "mixed"
|
||||
const showBlindtest = gameType === "blindtest" || gameType === "mixed"
|
||||
|
||||
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
|
||||
const connectedCount = snapshot.players.filter((p) => p.connected).length
|
||||
const blindtestAvailable = connectedCount >= 3
|
||||
|
||||
const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks)
|
||||
const canStart = rounds.length > 0
|
||||
const canStart =
|
||||
rounds.length > 0 && (!showBlindtest || blindtestAvailable)
|
||||
|
||||
async function start() {
|
||||
setError(null)
|
||||
|
|
@ -117,17 +122,29 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</section>
|
||||
|
||||
{isHost && (
|
||||
<div className="flex gap-2">
|
||||
{GAME_TYPES.map((t) => (
|
||||
<Button
|
||||
key={t.value}
|
||||
className="flex-1"
|
||||
variant={gameType === t.value ? "default" : "secondary"}
|
||||
onClick={() => updateSettings({ gameType: t.value })}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
{GAME_TYPES.map((t) => {
|
||||
const needsThree = t.value !== "quiz" && !blindtestAvailable
|
||||
return (
|
||||
<Button
|
||||
key={t.value}
|
||||
className="flex-1"
|
||||
variant={gameType === t.value ? "default" : "secondary"}
|
||||
disabled={needsThree}
|
||||
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
|
||||
onClick={() => updateSettings({ gameType: t.value })}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!blindtestAvailable && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
Le blindtest se débloque à 3 joueurs.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface YoutubeApi {
|
|||
pause: () => void
|
||||
seek: (seconds: number) => void
|
||||
time: () => number
|
||||
duration: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,6 +90,7 @@ export function useYoutube(youtubeId: string) {
|
|||
pause: () => playerRef.current?.pauseVideo(),
|
||||
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
|
||||
time: () => playerRef.current?.getCurrentTime() ?? 0,
|
||||
duration: () => playerRef.current?.getDuration() ?? 0,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue