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,7 +154,7 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
useEffect(() => {
|
||||||
const model = kiraModelRef.current;
|
const model = kiraModelRef.current;
|
||||||
const texIdx = clothTexIdxRef.current;
|
const texIdx = clothTexIdxRef.current;
|
||||||
@@ -164,16 +164,21 @@ export default function Live2DStage({ onKiraReady, onReady, outfit }: Props) {
|
|||||||
|
|
||||||
const swapTexture = async () => {
|
const swapTexture = async () => {
|
||||||
try {
|
try {
|
||||||
const textures = (model as any).internalModel?.textures;
|
const textures = model.textures;
|
||||||
if (!textures || !textures[texIdx]) return;
|
if (!textures || !textures[texIdx]) return;
|
||||||
|
|
||||||
const PIXI = await import('pixi.js');
|
const PIXI = await import('pixi.js');
|
||||||
const newBase = PIXI.BaseTexture.from(outfitSrc);
|
const newBase = new PIXI.BaseTexture(outfitSrc);
|
||||||
|
|
||||||
const doSwap = () => {
|
const doSwap = () => {
|
||||||
if (newBase.valid) {
|
// Replace the entire Texture object (not just baseTexture)
|
||||||
textures[texIdx].baseTexture = newBase;
|
const newTex = new PIXI.Texture(newBase);
|
||||||
textures[texIdx].update();
|
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];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,32 +13,100 @@ const LOFI_PLAYLISTS: Playlist[] = [
|
|||||||
{ id: 'lofi-synth', name: 'Synthwave lofi', videoId: 'MVPTmgNG4x0', icon: '🌃' },
|
{ id: 'lofi-synth', name: 'Synthwave lofi', videoId: 'MVPTmgNG4x0', icon: '🌃' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window { YT: any; onYouTubeIframeAPIReady: () => void; }
|
||||||
|
}
|
||||||
|
|
||||||
export default function MusicPlayer() {
|
export default function MusicPlayer() {
|
||||||
const [activeId, setActiveId] = useState<string>('lofi-girl');
|
const [activeId, setActiveId] = useState<string>('lofi-girl');
|
||||||
const [volume, setVolume] = useState(0.3);
|
const [volume, setVolume] = useState(0.3);
|
||||||
const [started, setStarted] = useState(false);
|
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];
|
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(() => {
|
useEffect(() => {
|
||||||
if (!started || !iframeRef.current) return;
|
if (document.getElementById('yt-iframe-api')) {
|
||||||
const target = Math.round(volume * 100);
|
if (window.YT?.Player) { apiReadyRef.current = true; }
|
||||||
// YouTube IFrame postMessage API for volume control
|
return;
|
||||||
iframeRef.current.contentWindow?.postMessage(
|
}
|
||||||
JSON.stringify({ event: 'command', func: 'setVolume', args: [target] }),
|
const tag = document.createElement('script');
|
||||||
'*'
|
tag.id = 'yt-iframe-api';
|
||||||
);
|
tag.src = 'https://www.youtube.com/iframe_api';
|
||||||
}, [volume, started]);
|
document.head.appendChild(tag);
|
||||||
|
window.onYouTubeIframeAPIReady = () => { apiReadyRef.current = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePlay = () => {
|
// Create player when started
|
||||||
setStarted(true);
|
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) => {
|
if (apiReadyRef.current) {
|
||||||
setActiveId(id);
|
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 (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@@ -49,10 +117,7 @@ export default function MusicPlayer() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-kira-violet/50">vol</span>
|
<span className="text-xs text-kira-violet/50">vol</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range" min="0" max="1" step="0.05"
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.05"
|
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||||
className="w-20 accent-kira-pink"
|
className="w-20 accent-kira-pink"
|
||||||
@@ -64,7 +129,7 @@ export default function MusicPlayer() {
|
|||||||
{LOFI_PLAYLISTS.map((p) => (
|
{LOFI_PLAYLISTS.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
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 ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium transition-all ${
|
||||||
activeId === p.id && started
|
activeId === p.id && started
|
||||||
? 'bg-kira-lav text-white shadow-md'
|
? 'bg-kira-lav text-white shadow-md'
|
||||||
@@ -77,32 +142,23 @@ export default function MusicPlayer() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden YouTube iframe — direct embed, no API */}
|
{/* Player area */}
|
||||||
{started && (
|
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ minHeight: 60 }}>
|
||||||
<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 }}>
|
|
||||||
{!started ? (
|
{!started ? (
|
||||||
<button
|
<button onClick={handlePlay}
|
||||||
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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<span className="text-lg">▶️</span>
|
<span className="text-lg">▶️</span>
|
||||||
<span className="text-sm font-medium">Start Lo-Fi</span>
|
<span className="text-sm font-medium">Start Lo-Fi</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-kira-plum/40 text-xs gap-2">
|
<div className="relative">
|
||||||
<span className="w-2 h-2 rounded-full bg-kira-mint animate-pulse inline-block" />
|
{/* YouTube player container — visible so autoplay works */}
|
||||||
<span>playing {active.name}</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user