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>
|
||||
)}
|
||||
|
||||
{/* Expression indicator */}
|
||||
{/* Expression indicator + Talk button */}
|
||||
{live2dReady && (
|
||||
<div className="mt-1 flex gap-1.5 flex-wrap justify-center">
|
||||
{EXPRESSIONS.map((expr) => (
|
||||
<button
|
||||
key={expr}
|
||||
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
|
||||
? 'bg-kira-pink text-white'
|
||||
: 'text-kira-plum/30 hover:text-kira-plum/60'
|
||||
}`}
|
||||
>
|
||||
{expr}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="mt-1 flex gap-1.5 flex-wrap justify-center">
|
||||
{EXPRESSIONS.map((expr) => (
|
||||
<button
|
||||
key={expr}
|
||||
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
|
||||
? 'bg-kira-pink text-white'
|
||||
: 'text-kira-plum/30 hover:text-kira-plum/60'
|
||||
}`}
|
||||
>
|
||||
{expr}
|
||||
</button>
|
||||
))}
|
||||
</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 */}
|
||||
@@ -251,6 +266,16 @@ export default function KiraAvatar(props: Props) {
|
||||
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,87 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
const LOFI_PLAYLISTS = [
|
||||
{ id: 'lofi-girl', name: 'lofi hip hop radio', url: 'https://www.youtube.com/embed/jfKfPfyJRdk', icon: '🎧' },
|
||||
{ id: 'lofi-chill', name: 'Chill lofi', url: 'https://www.youtube.com/embed/5qap5aO4i9A', icon: '🎵' },
|
||||
{ id: 'lofi-synth', name: 'Synthwave lofi', url: 'https://www.youtube.com/embed/MVPTmgNG4x0', icon: '🌃' },
|
||||
interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
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() {
|
||||
const [active, setActive] = useState<string | null>('lofi-girl');
|
||||
const [activeId, setActiveId] = useState<string>('lofi-girl');
|
||||
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(() => {
|
||||
// Post volume to YouTube iframe when it changes
|
||||
const timer = setTimeout(() => {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
JSON.stringify({ event: 'command', func: 'setVolume', args: [volume * 100] }),
|
||||
'*'
|
||||
);
|
||||
// Load YouTube IFrame API on first user interaction
|
||||
const startPlayer = useCallback(() => {
|
||||
if (apiReady.current) return;
|
||||
apiReady.current = true;
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
@@ -44,18 +104,16 @@ export default function MusicPlayer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{LOFI_PLAYLISTS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setActive(p.id)}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium transition-all
|
||||
${active === p.id
|
||||
onClick={() => started ? changeStation(p.id) : 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'
|
||||
: 'bg-white/50 text-kira-plum/60 hover:bg-kira-glow'
|
||||
}
|
||||
`}
|
||||
}`}
|
||||
>
|
||||
<span>{p.icon}</span>
|
||||
<span className="hidden sm:inline">{p.name}</span>
|
||||
@@ -63,19 +121,25 @@ export default function MusicPlayer() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-xl overflow-hidden" style={{ height: 80 }}>
|
||||
{active && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${LOFI_PLAYLISTS.find(p => p.id === active)?.url}?autoplay=1&controls=0&showinfo=0&loop=1&enablejsapi=1`}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ transform: 'scale(1.5)', transformOrigin: '0 0', opacity: 0.01 }}
|
||||
allow="autoplay"
|
||||
/>
|
||||
{/* Hidden YouTube player container */}
|
||||
<div id="kira-youtube-player" ref={wrapperRef} className="hidden" />
|
||||
|
||||
{/* Play button / status */}
|
||||
<div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user