diff --git a/frontend/src/components/Live2DStage.tsx b/frontend/src/components/Live2DStage.tsx index 1546738..4aaa0d4 100644 --- a/frontend/src/components/Live2DStage.tsx +++ b/frontend/src/components/Live2DStage.tsx @@ -154,29 +154,34 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Outfit swap effect + // Outfit swap effect — replaces the clothing texture (index 2) on the Live2D model useEffect(() => { const model = kiraModelRef.current; const texIdx = clothTexIdxRef.current; if (!model || texIdx < 0 || !outfit) return; const outfitSrc = `/live2d/models/kira/outfits/${outfit}.png`; - + const swapTexture = async () => { try { - const textures = (model as any).internalModel?.textures; + const textures = model.textures; if (!textures || !textures[texIdx]) return; - + const PIXI = await import('pixi.js'); - const newBase = PIXI.BaseTexture.from(outfitSrc); - + const newBase = new PIXI.BaseTexture(outfitSrc); + const doSwap = () => { - if (newBase.valid) { - textures[texIdx].baseTexture = newBase; - textures[texIdx].update(); + // Replace the entire Texture object (not just baseTexture) + const newTex = new PIXI.Texture(newBase); + textures[texIdx] = newTex; + + // Invalidate GL texture cache so the renderer re-binds next frame + const glCtxId = model.internalModel?.glContextID; + if (glCtxId !== undefined && newBase._glTextures) { + delete newBase._glTextures[glCtxId]; } }; - + if (newBase.valid) { doSwap(); } else { @@ -186,7 +191,7 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) { console.warn('[Live2DStage] outfit swap failed:', e); } }; - + swapTexture(); }, [outfit]); diff --git a/frontend/src/components/MusicPlayer.tsx b/frontend/src/components/MusicPlayer.tsx index 15d585d..de464d3 100644 --- a/frontend/src/components/MusicPlayer.tsx +++ b/frontend/src/components/MusicPlayer.tsx @@ -13,32 +13,100 @@ const LOFI_PLAYLISTS: Playlist[] = [ { id: 'lofi-synth', name: 'Synthwave lofi', videoId: 'MVPTmgNG4x0', icon: '🌃' }, ]; +declare global { + interface Window { YT: any; onYouTubeIframeAPIReady: () => void; } +} + export default function MusicPlayer() { const [activeId, setActiveId] = useState('lofi-girl'); const [volume, setVolume] = useState(0.3); const [started, setStarted] = useState(false); - const iframeRef = useRef(null); + const [playerState, setPlayerState] = useState(-1); + const playerRef = useRef(null); + const containerRef = useRef(null); + const apiReadyRef = useRef(false); const active = LOFI_PLAYLISTS.find((p) => p.id === activeId) ?? LOFI_PLAYLISTS[0]; - // Sync volume to iframe via YT postMessage API + // Load YT IFrame API once useEffect(() => { - if (!started || !iframeRef.current) return; - const target = Math.round(volume * 100); - // YouTube IFrame postMessage API for volume control - iframeRef.current.contentWindow?.postMessage( - JSON.stringify({ event: 'command', func: 'setVolume', args: [target] }), - '*' - ); - }, [volume, started]); + if (document.getElementById('yt-iframe-api')) { + if (window.YT?.Player) { apiReadyRef.current = true; } + return; + } + const tag = document.createElement('script'); + tag.id = 'yt-iframe-api'; + tag.src = 'https://www.youtube.com/iframe_api'; + document.head.appendChild(tag); + window.onYouTubeIframeAPIReady = () => { apiReadyRef.current = true; }; + }, []); - const handlePlay = () => { - setStarted(true); - }; + // Create player when started + useEffect(() => { + if (!started || !containerRef.current) return; - const changeStation = (id: string) => { - setActiveId(id); - }; + const createPlayer = () => { + if (playerRef.current) { + // Already exists, just cue new video + playerRef.current.loadVideoById(active.videoId); + return; + } + + if (!window.YT?.Player || !containerRef.current) return; + + playerRef.current = new window.YT.Player(containerRef.current, { + height: '80', + width: '100%', + videoId: active.videoId, + playerVars: { + autoplay: 1, + controls: 0, + modestbranding: 1, + loop: 1, + playlist: active.videoId, + fs: 0, + rel: 0, + iv_load_policy: 3, + }, + events: { + onReady: (e: any) => { + e.target.setVolume(Math.round(volume * 100)); + e.target.playVideo(); + setPlayerState(1); + }, + onStateChange: (e: any) => { + setPlayerState(e.data); + }, + }, + }); + }; + + if (apiReadyRef.current) { + createPlayer(); + } else { + const check = setInterval(() => { + if (apiReadyRef.current) { clearInterval(check); createPlayer(); } + }, 200); + return () => clearInterval(check); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [started]); + + // Volume sync + useEffect(() => { + if (playerRef.current?.setVolume) { + playerRef.current.setVolume(Math.round(volume * 100)); + } + }, [volume]); + + // Station change + useEffect(() => { + if (!started || !playerRef.current?.loadVideoById) return; + playerRef.current.loadVideoById(active.videoId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeId]); + + const handlePlay = () => setStarted(true); return (
@@ -49,10 +117,7 @@ export default function MusicPlayer() {
vol setVolume(parseFloat(e.target.value))} className="w-20 accent-kira-pink" @@ -64,7 +129,7 @@ export default function MusicPlayer() { {LOFI_PLAYLISTS.map((p) => (
- {/* Hidden YouTube iframe — direct embed, no API */} - {started && ( -