fix(outfit+lofi): proper texture swap + YT Player API

Outfit swap: replace entire Texture object + invalidate GL cache
Lo-fi: visible 80px player with YT Player API, proper playVideo() on user gesture
This commit is contained in:
2026-06-05 15:45:15 -04:00
parent a3b5477524
commit 45a1de936a
2 changed files with 114 additions and 53 deletions
+11 -6
View File
@@ -154,7 +154,7 @@ 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;
@@ -164,16 +164,21 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
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];
}
};
+97 -41
View File
@@ -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<string>('lofi-girl');
const [volume, setVolume] = useState(0.3);
const [started, setStarted] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const [playerState, setPlayerState] = useState<number>(-1);
const playerRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(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 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);
},
},
});
};
const changeStation = (id: string) => {
setActiveId(id);
};
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 (
<div className="p-4">
@@ -49,10 +117,7 @@ export default function MusicPlayer() {
<div className="flex items-center gap-2">
<span className="text-xs text-kira-violet/50">vol</span>
<input
type="range"
min="0"
max="1"
step="0.05"
type="range" min="0" max="1" step="0.05"
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="w-20 accent-kira-pink"
@@ -64,7 +129,7 @@ export default function MusicPlayer() {
{LOFI_PLAYLISTS.map((p) => (
<button
key={p.id}
onClick={() => changeStation(p.id)}
onClick={() => setActiveId(p.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium transition-all ${
activeId === p.id && started
? 'bg-kira-lav text-white shadow-md'
@@ -77,32 +142,23 @@ export default function MusicPlayer() {
))}
</div>
{/* Hidden YouTube iframe — direct embed, no API */}
{started && (
<iframe
ref={iframeRef}
src={`https://www.youtube.com/embed/${active.videoId}?autoplay=1&loop=1&playlist=${active.videoId}&controls=0&modestbranding=1&fs=0&rel=0&enablejsapi=1`}
width="1"
height="1"
allow="autoplay; encrypted-media"
style={{ position: 'absolute', opacity: 0, pointerEvents: 'none' }}
title="lofi player"
/>
)}
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
{/* Player area */}
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ minHeight: 60 }}>
{!started ? (
<button
onClick={handlePlay}
className="w-full h-full flex items-center justify-center gap-2 text-kira-plum/50 hover:text-kira-plum/80 hover:bg-white/20 transition-all"
>
<button onClick={handlePlay}
className="w-full h-[60px] flex items-center justify-center gap-2 text-kira-plum/50 hover:text-kira-plum/80 hover:bg-white/20 transition-all">
<span className="text-lg"></span>
<span className="text-sm font-medium">Start Lo-Fi</span>
</button>
) : (
<div className="w-full h-full flex items-center justify-center text-kira-plum/40 text-xs gap-2">
<span className="w-2 h-2 rounded-full bg-kira-mint animate-pulse inline-block" />
<span>playing {active.name}</span>
<div className="relative">
{/* YouTube player container — visible so autoplay works */}
<div ref={containerRef} className="w-full rounded-xl overflow-hidden" style={{ height: 80 }} />
{playerState !== 1 && playerState !== 3 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 text-white text-xs">
loading stream...
</div>
)}
</div>
)}
</div>