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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 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 (
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user