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:
@@ -223,25 +223,40 @@ 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">
|
<>
|
||||||
{EXPRESSIONS.map((expr) => (
|
<div className="mt-1 flex gap-1.5 flex-wrap justify-center">
|
||||||
<button
|
{EXPRESSIONS.map((expr) => (
|
||||||
key={expr}
|
<button
|
||||||
onClick={() => {
|
key={expr}
|
||||||
try { modelRef.current?.expression(expr); setCurrentExpression(expr); } catch {}
|
onClick={() => {
|
||||||
}}
|
try { modelRef.current?.expression(expr); setCurrentExpression(expr); } catch {}
|
||||||
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
|
}}
|
||||||
currentExpression === expr
|
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
|
||||||
? 'bg-kira-pink text-white'
|
currentExpression === expr
|
||||||
: 'text-kira-plum/30 hover:text-kira-plum/60'
|
? 'bg-kira-pink text-white'
|
||||||
}`}
|
: 'text-kira-plum/30 hover:text-kira-plum/60'
|
||||||
>
|
}`}
|
||||||
{expr}
|
>
|
||||||
</button>
|
{expr}
|
||||||
))}
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}, 500);
|
}
|
||||||
return () => clearTimeout(timer);
|
};
|
||||||
|
|
||||||
|
// Volume
|
||||||
|
useEffect(() => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
playerRef.current.setVolume(volume * 100);
|
||||||
|
}
|
||||||
}, [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"
|
||||||
|
>
|
||||||
|
<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 {LOFI_PLAYLISTS.find((p) => p.id === activeId)?.name}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-kira-plum/30 text-xs">
|
|
||||||
{active ? '🎵 streaming...' : 'pick a station'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user