nerdware/apps/web/src/lib/youtube.ts
AyoubBenziza a9eb6098f2 feat(blindtest): contributor-as-DJ scoring, 3-player gate, 16:9 player + seek
- 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>
2026-06-10 14:30:47 +02:00

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 }
}