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;
|
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 {
|
.song-select-screen__content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useGameInput } from '../../input/useGameInput'
|
import { useGameInput } from '../../input/useGameInput'
|
||||||
|
|
@ -26,8 +26,9 @@ export function SongSelectScreen() {
|
||||||
const resetStats = useGameStatsStore((state) => state.resetStats)
|
const resetStats = useGameStatsStore((state) => state.resetStats)
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
const activeSong = SONGS[currentIndex]
|
const activeSong = SONGS[currentIndex]
|
||||||
|
const ytPreviewRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useSongPreview(activeSong)
|
useSongPreview(activeSong, ytPreviewRef.current)
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
setCurrentSong(activeSong)
|
setCurrentSong(activeSong)
|
||||||
|
|
@ -74,6 +75,8 @@ export function SongSelectScreen() {
|
||||||
difficulty={activeSong.difficulty}
|
difficulty={activeSong.difficulty}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div ref={ytPreviewRef} className="song-select-screen__yt-preview" />
|
||||||
|
|
||||||
<div className="song-select-screen__content">
|
<div className="song-select-screen__content">
|
||||||
<header className="song-select-screen__header">
|
<header className="song-select-screen__header">
|
||||||
<span className="song-select-screen__disco-ball" aria-hidden="true" />
|
<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 PREVIEW_START_SEC = 30
|
||||||
const FADE_IN_MS = 1000
|
const FADE_IN_MS = 1000
|
||||||
const FADE_STEP_MS = 50
|
const FADE_STEP_MS = 50
|
||||||
const FADE_OUT_MS = 400
|
|
||||||
const MAX_VOLUME = 0.5
|
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)
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fade out + destroy previous preview
|
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = null
|
cleanupRef.current = null
|
||||||
|
|
||||||
if (song.youtubeVideoId) {
|
if (song.youtubeVideoId && ytHostEl) {
|
||||||
const cleanup = startYouTubePreview(song)
|
const cleanup = startYouTubePreview(song, ytHostEl)
|
||||||
cleanupRef.current = cleanup
|
cleanupRef.current = cleanup
|
||||||
return cleanup
|
return cleanup
|
||||||
}
|
}
|
||||||
|
|
@ -26,11 +28,11 @@ export function useSongPreview(song: Song) {
|
||||||
const cleanup = startLocalPreview(song)
|
const cleanup = startLocalPreview(song)
|
||||||
cleanupRef.current = cleanup
|
cleanupRef.current = cleanup
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [song.id])
|
}, [song.id, ytHostEl])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Local audio preview (existing approach)
|
// Local audio preview
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function startLocalPreview(song: Song): () => void {
|
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 destroyed = false
|
||||||
let player: YT.Player | null = null
|
let player: YT.Player | null = null
|
||||||
let fadeIv = 0
|
let fadeIv = 0
|
||||||
let host: HTMLDivElement | null = null
|
|
||||||
|
// Clear any previous player content
|
||||||
|
host.innerHTML = ''
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
loadYouTubeApi().then(() => {
|
loadYouTubeApi().then(() => {
|
||||||
if (destroyed) return
|
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')
|
const div = document.createElement('div')
|
||||||
host.appendChild(div)
|
host.appendChild(div)
|
||||||
|
|
||||||
|
|
@ -92,8 +87,8 @@ function startYouTubePreview(song: Song): () => void {
|
||||||
|
|
||||||
player = new YT.Player(div, {
|
player = new YT.Player(div, {
|
||||||
videoId: song.youtubeVideoId!,
|
videoId: song.youtubeVideoId!,
|
||||||
width: '1',
|
width: '100%',
|
||||||
height: '1',
|
height: '100%',
|
||||||
playerVars: {
|
playerVars: {
|
||||||
autoplay: 1,
|
autoplay: 1,
|
||||||
controls: 0,
|
controls: 0,
|
||||||
|
|
@ -114,7 +109,6 @@ function startYouTubePreview(song: Song): () => void {
|
||||||
onStateChange(e: YT.OnStateChangeEvent) {
|
onStateChange(e: YT.OnStateChangeEvent) {
|
||||||
if (destroyed || !player) return
|
if (destroyed || !player) return
|
||||||
if (e.data === YT.PlayerState.PLAYING) {
|
if (e.data === YT.PlayerState.PLAYING) {
|
||||||
// Fade in
|
|
||||||
const maxVol = MAX_VOLUME * 100
|
const maxVol = MAX_VOLUME * 100
|
||||||
const step = (maxVol * FADE_STEP_MS) / FADE_IN_MS
|
const step = (maxVol * FADE_STEP_MS) / FADE_IN_MS
|
||||||
fadeIv = window.setInterval(() => {
|
fadeIv = window.setInterval(() => {
|
||||||
|
|
@ -138,23 +132,9 @@ function startYouTubePreview(song: Song): () => void {
|
||||||
destroyed = true
|
destroyed = true
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
clearInterval(fadeIv)
|
clearInterval(fadeIv)
|
||||||
// Fade out quickly then destroy
|
|
||||||
if (player) {
|
if (player) {
|
||||||
const p = player
|
try { player.destroy() } catch { /* ignore */ }
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
host.innerHTML = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue