fix: missing mic toggle in Live2D view + YouTube autoplay

KiraAvatar: Added Talk mic button to Live2D view (was only in
AnimatedAvatar fallback). Includes listening-pulse animation.

MusicPlayer: Replaced hidden YouTube iframe with proper IFrame
Player API. Now starts on explicit user click (Start Lo-Fi button),
complying with browser autoplay policies. Supports station
switching and volume control after playback starts.
This commit is contained in:
2026-06-04 12:06:16 -04:00
parent bee428ae0c
commit 3d3df64d7c
2 changed files with 143 additions and 54 deletions
+26 -1
View File
@@ -223,8 +223,9 @@ export default function KiraAvatar(props: Props) {
</div> </div>
)} )}
{/* Expression indicator */} {/* Expression indicator + Talk button */}
{live2dReady && ( {live2dReady && (
<>
<div className="mt-1 flex gap-1.5 flex-wrap justify-center"> <div className="mt-1 flex gap-1.5 flex-wrap justify-center">
{EXPRESSIONS.map((expr) => ( {EXPRESSIONS.map((expr) => (
<button <button
@@ -242,6 +243,20 @@ export default function KiraAvatar(props: Props) {
</button> </button>
))} ))}
</div> </div>
{/* Talk button — mic toggle */}
<button
onClick={props.onTalkToggle}
className={`mt-2 flex items-center gap-2 px-5 py-2 rounded-full text-sm font-bold transition-all ${
props.isListening
? 'bg-red-400 text-white shadow-lg scale-105 animate-listening-pulse'
: 'bg-gradient-to-r from-kira-pink to-kira-lav text-white hover:shadow-lg hover:scale-105'
}`}
>
<span className="text-base">{props.isListening ? '⏹️' : '🎤'}</span>
{props.isListening ? 'Listening...' : 'Talk to Kira'}
</button>
</>
)} )}
{/* Status bar */} {/* Status bar */}
@@ -251,6 +266,16 @@ export default function KiraAvatar(props: Props) {
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'} {props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'}
</span> </span>
</div> </div>
<style>{`
@keyframes listening-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); }
50% { box-shadow: 0 0 0 12px rgba(248, 113, 113, 0); }
}
.animate-listening-pulse {
animation: listening-pulse 1.5s infinite;
}
`}</style>
</div> </div>
); );
} }
+100 -36
View File
@@ -1,27 +1,87 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
const LOFI_PLAYLISTS = [ interface Playlist {
{ id: 'lofi-girl', name: 'lofi hip hop radio', url: 'https://www.youtube.com/embed/jfKfPfyJRdk', icon: '🎧' }, id: string;
{ id: 'lofi-chill', name: 'Chill lofi', url: 'https://www.youtube.com/embed/5qap5aO4i9A', icon: '🎵' }, name: string;
{ id: 'lofi-synth', name: 'Synthwave lofi', url: 'https://www.youtube.com/embed/MVPTmgNG4x0', icon: '🌃' }, videoId: string;
icon: string;
}
const LOFI_PLAYLISTS: Playlist[] = [
{ id: 'lofi-girl', name: 'lofi hip hop radio', videoId: 'jfKfPfyJRdk', icon: '🎧' },
{ id: 'lofi-chill', name: 'Chill lofi', videoId: '5qap5aO4i9A', icon: '🎵' },
{ id: 'lofi-synth', name: 'Synthwave lofi', videoId: 'MVPTmgNG4x0', icon: '🌃' },
]; ];
export default function MusicPlayer() { export default function MusicPlayer() {
const [active, setActive] = useState<string | null>('lofi-girl'); const [activeId, setActiveId] = useState<string>('lofi-girl');
const [volume, setVolume] = useState(0.3); const [volume, setVolume] = useState(0.3);
const iframeRef = useRef<HTMLIFrameElement>(null); const [started, setStarted] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<any>(null);
const apiReady = useRef(false);
useEffect(() => { // Load YouTube IFrame API on first user interaction
// Post volume to YouTube iframe when it changes const startPlayer = useCallback(() => {
const timer = setTimeout(() => { if (apiReady.current) return;
if (iframeRef.current?.contentWindow) { apiReady.current = true;
iframeRef.current.contentWindow.postMessage(
JSON.stringify({ event: 'command', func: 'setVolume', args: [volume * 100] }), const tag = document.createElement('script');
'*' tag.src = 'https://www.youtube.com/iframe_api';
); const first = document.getElementsByTagName('script')[0];
first.parentNode?.insertBefore(tag, first);
(window as any).onYouTubeIframeAPIReady = () => {
const active = LOFI_PLAYLISTS.find((p) => p.id === activeId);
if (!active) return;
playerRef.current = new (window as any).YT.Player('kira-youtube-player', {
height: '0',
width: '0',
videoId: active.videoId,
playerVars: {
autoplay: 1,
controls: 0,
loop: 1,
playlist: active.videoId,
enablejsapi: 1,
},
events: {
onReady: (e: any) => {
e.target.setVolume(volume * 100);
e.target.playVideo();
},
},
});
};
}, [activeId, volume]);
// Handle play button click (first user gesture)
const handlePlay = () => {
if (!started) {
setStarted(true);
startPlayer();
}
};
// Change station
const changeStation = (id: string) => {
setActiveId(id);
if (playerRef.current && (window as any).YT) {
const active = LOFI_PLAYLISTS.find((p) => p.id === id);
if (active) {
playerRef.current.loadVideoById(active.videoId);
playerRef.current.setVolume(volume * 100);
playerRef.current.playVideo();
}
}
};
// Volume
useEffect(() => {
if (playerRef.current) {
playerRef.current.setVolume(volume * 100);
} }
}, 500);
return () => clearTimeout(timer);
}, [volume]); }, [volume]);
return ( return (
@@ -44,18 +104,16 @@ export default function MusicPlayer() {
</div> </div>
</div> </div>
<div className="flex gap-2 mb-3"> <div className="flex gap-2 mb-3 flex-wrap">
{LOFI_PLAYLISTS.map((p) => ( {LOFI_PLAYLISTS.map((p) => (
<button <button
key={p.id} key={p.id}
onClick={() => setActive(p.id)} onClick={() => started ? changeStation(p.id) : setActiveId(p.id)}
className={` className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium transition-all ${
flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium transition-all activeId === p.id && started
${active === p.id
? 'bg-kira-lav text-white shadow-md' ? 'bg-kira-lav text-white shadow-md'
: 'bg-white/50 text-kira-plum/60 hover:bg-kira-glow' : 'bg-white/50 text-kira-plum/60 hover:bg-kira-glow'
} }`}
`}
> >
<span>{p.icon}</span> <span>{p.icon}</span>
<span className="hidden sm:inline">{p.name}</span> <span className="hidden sm:inline">{p.name}</span>
@@ -63,19 +121,25 @@ export default function MusicPlayer() {
))} ))}
</div> </div>
<div className="relative rounded-xl overflow-hidden" style={{ height: 80 }}> {/* Hidden YouTube player container */}
{active && ( <div id="kira-youtube-player" ref={wrapperRef} className="hidden" />
<iframe
ref={iframeRef} {/* Play button / status */}
src={`${LOFI_PLAYLISTS.find(p => p.id === active)?.url}?autoplay=1&controls=0&showinfo=0&loop=1&enablejsapi=1`} <div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
className="absolute inset-0 w-full h-full pointer-events-none" {!started ? (
style={{ transform: 'scale(1.5)', transformOrigin: '0 0', opacity: 0.01 }} <button
allow="autoplay" 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"
)} >
<div className="absolute inset-0 flex items-center justify-center text-kira-plum/30 text-xs"> <span className="text-lg"></span>
{active ? '🎵 streaming...' : 'pick a station'} <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 {LOFI_PLAYLISTS.find((p) => p.id === activeId)?.name}</span>
</div> </div>
)}
</div> </div>
</div> </div>
); );