fix(lofi): replace YT IFrame API with direct iframe embed

The YT Player API silently fails to autoplay in hidden iframes.
Replaced with a direct <iframe> embed with allow='autoplay; encrypted-media'.
- On 'Start Lo-Fi': creates iframe with autoplay=1
- Station change: remounts iframe with new videoId
- Volume: postMessage API to YT iframe (best effort)
- Much simpler, no external script dependency
This commit is contained in:
2026-06-05 15:20:00 -04:00
parent 5dbe30b43c
commit ff6bf46724
+28 -79
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect } from 'react';
interface Playlist { interface Playlist {
id: string; id: string;
@@ -17,90 +17,29 @@ 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 [playing, setPlaying] = useState(false); const iframeRef = useRef<HTMLIFrameElement>(null);
const playerRef = useRef<any>(null);
const volumeRef = useRef(volume);
const activeIdRef = useRef(activeId);
const readyResolveRef = useRef<(() => void) | null>(null);
useEffect(() => { volumeRef.current = volume; }, [volume]); const active = LOFI_PLAYLISTS.find((p) => p.id === activeId) ?? LOFI_PLAYLISTS[0];
useEffect(() => { activeIdRef.current = activeId; }, [activeId]);
// Load YouTube IFrame API and create player // Sync volume to iframe via YT postMessage API
const initPlayer = useCallback(() => { useEffect(() => {
const active = LOFI_PLAYLISTS.find((p) => p.id === activeIdRef.current); if (!started || !iframeRef.current) return;
if (!active || playerRef.current) return; const target = Math.round(volume * 100);
// YouTube IFrame postMessage API for volume control
const createYT = () => { iframeRef.current.contentWindow?.postMessage(
playerRef.current = new (window as any).YT.Player('kira-youtube-player', { JSON.stringify({ event: 'command', func: 'setVolume', args: [target] }),
height: '1', '*'
width: '1', );
videoId: active.videoId, }, [volume, started]);
playerVars: {
autoplay: 1,
controls: 0,
loop: 1,
playlist: active.videoId,
enablejsapi: 1,
modestbranding: 1,
fs: 0,
rel: 0,
},
events: {
onReady: (e: any) => {
e.target.setVolume(Math.round(volumeRef.current * 100));
e.target.playVideo();
setPlaying(true);
},
onStateChange: (e: any) => {
// YT.PlayerState.PLAYING = 1, PAUSED = 2, ENDED = 0
setPlaying(e.data === 1);
},
onError: (e: any) => {
console.warn('[MusicPlayer] YouTube error:', e.data);
},
},
});
};
if ((window as any).YT?.Player) {
createYT();
} else {
// Load the script
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
(window as any).onYouTubeIframeAPIReady = createYT;
}
}, []);
const handlePlay = () => { const handlePlay = () => {
setStarted(true); setStarted(true);
initPlayer();
}; };
const changeStation = (id: string) => { const changeStation = (id: string) => {
setActiveId(id); setActiveId(id);
if (playerRef.current && (window as any).YT) {
const active = LOFI_PLAYLISTS.find((p) => p.id === id);
if (active) {
try {
playerRef.current.loadVideoById(active.videoId);
playerRef.current.setVolume(Math.round(volume * 100));
} catch {}
}
}
}; };
// Volume sync
useEffect(() => {
try {
if (playerRef.current?.setVolume) {
playerRef.current.setVolume(Math.round(volume * 100));
}
} catch {}
}, [volume]);
return ( return (
<div className="p-4"> <div className="p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -125,7 +64,7 @@ export default function MusicPlayer() {
{LOFI_PLAYLISTS.map((p) => ( {LOFI_PLAYLISTS.map((p) => (
<button <button
key={p.id} key={p.id}
onClick={() => started ? changeStation(p.id) : setActiveId(p.id)} onClick={() => changeStation(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'
@@ -138,8 +77,18 @@ export default function MusicPlayer() {
))} ))}
</div> </div>
{/* YouTube player: must have real dimensions for the API to work */} {/* Hidden YouTube iframe — direct embed, no API */}
<div id="kira-youtube-player" style={{ width: 1, height: 1, opacity: 0, position: 'absolute', pointerEvents: 'none' }} /> {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 }}> <div className="relative rounded-xl overflow-hidden bg-white/30" style={{ height: 60 }}>
{!started ? ( {!started ? (
@@ -152,8 +101,8 @@ export default function MusicPlayer() {
</button> </button>
) : ( ) : (
<div className="w-full h-full flex items-center justify-center text-kira-plum/40 text-xs gap-2"> <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 animate-pulse inline-block ${playing ? 'bg-kira-mint' : 'bg-kira-plum/30'}`} /> <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> <span>playing {active.name}</span>
</div> </div>
)} )}
</div> </div>