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
parent
8f642cc301
commit
808da507f6
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue