Use visible YouTube player for song preview (ToS compliance)

Preview now uses YouTube IFrame API with a visible 240x135 player in the
top-left corner of the song select screen, matching the game page layout.
Falls back to local audio for songs without YouTube config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
main
Claude 2026-04-14 23:34:57 +00:00
parent 8f642cc301
commit 808da507f6
3 changed files with 44 additions and 40 deletions

View File

@ -56,6 +56,27 @@
opacity: 0.35;
}
.song-select-screen__yt-preview {
position: fixed;
top: 12px;
left: 12px;
width: 240px;
height: 135px;
z-index: 10;
border-radius: 10px;
overflow: hidden;
border: 2px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
.song-select-screen__yt-preview iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.song-select-screen__content {
position: relative;
z-index: 2;

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { useNavigate } from 'react-router-dom'
import { useGameInput } from '../../input/useGameInput'
@ -26,8 +26,9 @@ export function SongSelectScreen() {
const resetStats = useGameStatsStore((state) => state.resetStats)
const [currentIndex, setCurrentIndex] = useState(0)
const activeSong = SONGS[currentIndex]
const ytPreviewRef = useRef<HTMLDivElement>(null)
useSongPreview(activeSong)
useSongPreview(activeSong, ytPreviewRef.current)
const handleConfirm = () => {
setCurrentSong(activeSong)
@ -74,6 +75,8 @@ export function SongSelectScreen() {
difficulty={activeSong.difficulty}
/>
<div ref={ytPreviewRef} className="song-select-screen__yt-preview" />
<div className="song-select-screen__content">
<header className="song-select-screen__header">
<span className="song-select-screen__disco-ball" aria-hidden="true" />

View File

@ -6,19 +6,21 @@ const PREVIEW_DELAY_MS = 300
const PREVIEW_START_SEC = 30
const FADE_IN_MS = 1000
const FADE_STEP_MS = 50
const FADE_OUT_MS = 400
const MAX_VOLUME = 0.5
export function useSongPreview(song: Song) {
/**
* Plays a song preview. For YouTube songs, mounts a player into `ytHostEl`.
* The caller must render a visible container and pass it via ref.
*/
export function useSongPreview(song: Song, ytHostEl: HTMLElement | null) {
const cleanupRef = useRef<(() => void) | null>(null)
useEffect(() => {
// Fade out + destroy previous preview
cleanupRef.current?.()
cleanupRef.current = null
if (song.youtubeVideoId) {
const cleanup = startYouTubePreview(song)
if (song.youtubeVideoId && ytHostEl) {
const cleanup = startYouTubePreview(song, ytHostEl)
cleanupRef.current = cleanup
return cleanup
}
@ -26,11 +28,11 @@ export function useSongPreview(song: Song) {
const cleanup = startLocalPreview(song)
cleanupRef.current = cleanup
return cleanup
}, [song.id])
}, [song.id, ytHostEl])
}
// ---------------------------------------------------------------------------
// Local audio preview (existing approach)
// Local audio preview
// ---------------------------------------------------------------------------
function startLocalPreview(song: Song): () => void {
@ -62,28 +64,21 @@ function startLocalPreview(song: Song): () => void {
}
// ---------------------------------------------------------------------------
// YouTube preview
// YouTube preview (visible player)
// ---------------------------------------------------------------------------
function startYouTubePreview(song: Song): () => void {
function startYouTubePreview(song: Song, host: HTMLElement): () => void {
let destroyed = false
let player: YT.Player | null = null
let fadeIv = 0
let host: HTMLDivElement | null = null
// Clear any previous player content
host.innerHTML = ''
const timer = window.setTimeout(() => {
loadYouTubeApi().then(() => {
if (destroyed) return
// Create an off-screen container for the iframe
host = document.createElement('div')
Object.assign(host.style, {
position: 'fixed', width: '1px', height: '1px',
opacity: '0', pointerEvents: 'none', overflow: 'hidden',
top: '-10px', left: '-10px',
})
document.body.appendChild(host)
const div = document.createElement('div')
host.appendChild(div)
@ -92,8 +87,8 @@ function startYouTubePreview(song: Song): () => void {
player = new YT.Player(div, {
videoId: song.youtubeVideoId!,
width: '1',
height: '1',
width: '100%',
height: '100%',
playerVars: {
autoplay: 1,
controls: 0,
@ -114,7 +109,6 @@ function startYouTubePreview(song: Song): () => void {
onStateChange(e: YT.OnStateChangeEvent) {
if (destroyed || !player) return
if (e.data === YT.PlayerState.PLAYING) {
// Fade in
const maxVol = MAX_VOLUME * 100
const step = (maxVol * FADE_STEP_MS) / FADE_IN_MS
fadeIv = window.setInterval(() => {
@ -138,23 +132,9 @@ function startYouTubePreview(song: Song): () => void {
destroyed = true
clearTimeout(timer)
clearInterval(fadeIv)
// Fade out quickly then destroy
if (player) {
const p = player
const maxVol = p.getVolume()
const step = (maxVol * FADE_STEP_MS) / FADE_OUT_MS
const outIv = window.setInterval(() => {
const cur = p.getVolume()
if (cur - step <= 0) {
clearInterval(outIv)
try { p.destroy() } catch { /* ignore */ }
host?.remove()
} else {
p.setVolume(cur - step)
}
}, FADE_STEP_MS)
} else {
host?.remove()
try { player.destroy() } catch { /* ignore */ }
}
host.innerHTML = ''
}
}