- DJ is now the track's contributor (controls their own song); their identity is sent only to them so "who added" stays non-trivial. The contributor doesn't vote and is excluded from the eligible-voters count. - Scoring: the contributor earns nothing except in who_added/mixed, where they get a misdirection bonus (50) per player who guesses the wrong adder. - Blindtest (and mixed) require >= 3 connected players: lobby disables the options with a hint, and game:start rejects with NEED_THREE. - Player card is now 16:9 and full width — the video banner shows at reveal; DJ gets a seek bar (current/duration) to jump anywhere, plus play/pause/restart. - Mixed game order is shuffled (no predictable quiz/blindtest alternation). Verified e2e (3 players): guard rejects at 2, DJ id hidden from others, misdirection scoring correct. 23 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
2.5 KiB
TypeScript
99 lines
2.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react"
|
|
|
|
let apiPromise: Promise<void> | null = null
|
|
|
|
/** Charge l'API IFrame YouTube une seule fois. */
|
|
function loadYoutubeApi(): Promise<void> {
|
|
if (window.YT?.Player) {
|
|
return Promise.resolve()
|
|
}
|
|
if (apiPromise) {
|
|
return apiPromise
|
|
}
|
|
apiPromise = new Promise((resolve) => {
|
|
const previous = window.onYouTubeIframeAPIReady
|
|
window.onYouTubeIframeAPIReady = () => {
|
|
previous?.()
|
|
resolve()
|
|
}
|
|
const tag = document.createElement("script")
|
|
tag.src = "https://www.youtube.com/iframe_api"
|
|
document.head.appendChild(tag)
|
|
})
|
|
return apiPromise
|
|
}
|
|
|
|
export interface YoutubeApi {
|
|
play: () => void
|
|
pause: () => void
|
|
seek: (seconds: number) => void
|
|
time: () => number
|
|
duration: () => number
|
|
}
|
|
|
|
/**
|
|
* Crée un lecteur YouTube pour `youtubeId`. Renvoie un ref à poser sur un div
|
|
* hôte, l'état "prêt", et une API impérative (play/pause/seek/time).
|
|
*/
|
|
export function useYoutube(youtubeId: string) {
|
|
const hostRef = useRef<HTMLDivElement>(null)
|
|
const playerRef = useRef<YT.Player | null>(null)
|
|
const [ready, setReady] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const host = hostRef.current
|
|
if (!host) {
|
|
return
|
|
}
|
|
setReady(false)
|
|
// YT remplace l'élément fourni par une iframe → on lui donne un enfant jetable
|
|
// pour que React ne gère jamais ce nœud.
|
|
const inner = document.createElement("div")
|
|
host.appendChild(inner)
|
|
|
|
loadYoutubeApi().then(() => {
|
|
if (cancelled || !window.YT) {
|
|
return
|
|
}
|
|
playerRef.current = new window.YT.Player(inner, {
|
|
videoId: youtubeId,
|
|
playerVars: {
|
|
autoplay: 0,
|
|
controls: 0,
|
|
disablekb: 1,
|
|
modestbranding: 1,
|
|
rel: 0,
|
|
playsinline: 1,
|
|
},
|
|
events: {
|
|
onReady: () => {
|
|
if (!cancelled) {
|
|
setReady(true)
|
|
}
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
playerRef.current?.destroy()
|
|
playerRef.current = null
|
|
host.replaceChildren()
|
|
}
|
|
}, [youtubeId])
|
|
|
|
const api = useMemo<YoutubeApi>(
|
|
() => ({
|
|
play: () => playerRef.current?.playVideo(),
|
|
pause: () => playerRef.current?.pauseVideo(),
|
|
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
|
|
time: () => playerRef.current?.getCurrentTime() ?? 0,
|
|
duration: () => playerRef.current?.getDuration() ?? 0,
|
|
}),
|
|
[]
|
|
)
|
|
|
|
return { hostRef, ready, api }
|
|
}
|