"use client"; import { useEffect, useRef, useState } from "react"; import { useJukeboxStore } from "../store"; import { startSpotifyAuth, buildRedirectUri, loadToken, exchangeCodeForToken, loadCallbackBaseUrl, saveCallbackBaseUrl, loadAuthState, } from "../auth"; import type { SpotifyTrack } from "../spotifyApi"; type JukeboxPanelProps = { onClose: () => void; selectedAgentName?: string | null; client?: unknown; }; // --------------------------------------------------------------------------- // Root panel // --------------------------------------------------------------------------- export function JukeboxPanel({ onClose }: JukeboxPanelProps) { const { view, init } = useJukeboxStore(); useEffect(() => { init(); const handleMessage = (event: MessageEvent) => { const callbackBaseUrl = loadCallbackBaseUrl(); if (!callbackBaseUrl) return; const callbackOrigin = new URL(callbackBaseUrl).origin; if (event.origin !== callbackOrigin) return; const payload = event.data as | { type?: string; code?: string; error?: string; state?: string; } | undefined; if (!payload || payload.type !== "soundclaw-spotify-auth") return; if (payload.error) return; if (!payload.code) return; if (payload.state !== loadAuthState()) return; const { clientId, setToken } = useJukeboxStore.getState(); void exchangeCodeForToken(payload.code, clientId, buildRedirectUri(callbackBaseUrl)).then( (ok) => { if (!ok) return; const token = loadToken(); if (token) setToken(token); }, ); }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, []); return (
{/* Header. */}
🎵
Soundclaw

Office Jukebox

{/* Body. */}
{view === "setup" ? : }
); } // --------------------------------------------------------------------------- // Setup view — shown before the user authenticates // --------------------------------------------------------------------------- function SetupView() { const { clientId, setClientId } = useJukeboxStore(); const [inputId, setInputId] = useState(clientId); const [callbackBaseUrl, setCallbackBaseUrl] = useState(() => loadCallbackBaseUrl()); const [isRedirecting, setIsRedirecting] = useState(false); const redirectUri = buildRedirectUri(callbackBaseUrl); const localhostOrigin = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.host}` : ""; const callbackLooksValid = /^https:\/\/.+/i.test(callbackBaseUrl.trim()); const handleConnect = async () => { if (!inputId.trim() || !redirectUri) return; saveCallbackBaseUrl(callbackBaseUrl); setClientId(inputId.trim()); setIsRedirecting(true); const popup = window.open( "", "soundclaw-spotify-auth", "popup=yes,width=520,height=760,resizable=yes,scrollbars=yes", ); if (!popup) { setIsRedirecting(false); return; } popup.document.write("

Redirecting to Spotify...

"); await startSpotifyAuth(inputId.trim(), redirectUri, popup); setIsRedirecting(false); }; return (
Keep Claw3D open on {localhostOrigin}. Spotify will redirect to your ngrok callback, which sends the auth code back into this window.
{!callbackLooksValid && callbackBaseUrl.trim().length > 0 && (
Enter a valid HTTPS ngrok URL, for example https://your-id.ngrok-free.app.
)} {/* What you need card. */}

⚠️ What you need before connecting

  1. 1 Go to{" "} developer.spotify.com/dashboard {" "} and create an app (or use an existing one).
  2. 2 In your Spotify app settings, add this Redirect URI:
  3. {redirectUri && (
  4. {redirectUri}
  5. )}
  6. 3 Paste your public ngrok URL below, then use the exact redirect shown here in Spotify.
  7. 4 Keep this local office tab open while authenticating. The popup callback will hand the code back to this page.
  8. 5 Make sure Spotify is open and playing on at least one device before using playback controls.
setCallbackBaseUrl(e.target.value)} placeholder="https://your-id.ngrok-free.app" className="w-full rounded-xl border border-white/10 bg-slate-900 px-4 py-2.5 font-mono text-sm text-white placeholder-slate-600 focus:border-cyan-500/50 focus:outline-none focus:ring-1 focus:ring-cyan-500/30" />

This is only used for the Spotify OAuth callback bridge. Your app can stay on {localhostOrigin}.

{/* Client ID input. */}
setInputId(e.target.value)} placeholder="e.g. 1a2b3c4d5e6f…" className="w-full rounded-xl border border-white/10 bg-slate-900 px-4 py-2.5 font-mono text-sm text-white placeholder-slate-600 focus:border-cyan-500/50 focus:outline-none focus:ring-1 focus:ring-cyan-500/30" />

Stored locally in your browser. Never sent to any server other than Spotify.

); } // --------------------------------------------------------------------------- // Player view — shown after authentication // --------------------------------------------------------------------------- function PlayerView() { const { playerState, searchResults, searchQuery, isSearching, isLoadingPlayer, error, refreshPlayer, search, setSearchQuery, play, pause, resume, next, previous, volume, disconnect, } = useJukeboxStore(); const searchDebounce = useRef | null>(null); // Poll player state every 5 seconds. useEffect(() => { refreshPlayer(); const id = window.setInterval(() => { void refreshPlayer(); }, 5000); return () => window.clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleSearchChange = (value: string) => { setSearchQuery(value); if (searchDebounce.current) clearTimeout(searchDebounce.current); searchDebounce.current = setTimeout(() => { if (value.trim()) void search(value); }, 400); }; const track = playerState?.track; const albumArt = track?.album.images[0]?.url ?? null; return (
{error && (
{error}
)} {/* Now playing. */}
Now playing
{isLoadingPlayer && !track ? (
Loading player…
) : track ? (
{albumArt && ( // eslint-disable-next-line @next/next/no-img-element {track.album.name} )}
{track.name}
{track.artists.map((a) => a.name).join(", ")}
{track.album.name}
) : (

No active playback. Open Spotify on a device first, then hit play.

)} {/* Transport controls. */}
void previous()} title="Previous" /> {playerState?.isPlaying ? ( void pause()} title="Pause" large /> ) : ( void resume()} title="Play" large /> )} void next()} title="Next" />
{/* Volume. */} {playerState && (
🔈 void volume(Number(e.target.value))} className="h-1.5 w-full cursor-pointer accent-cyan-400" /> {playerState.volumePercent}%
)}
{/* Search. */}
Search tracks
handleSearchChange(e.target.value)} placeholder="Artist, song, or album…" className="w-full rounded-xl border border-white/10 bg-slate-900 py-2.5 pl-4 pr-10 text-sm text-white placeholder-slate-600 focus:border-cyan-500/50 focus:outline-none focus:ring-1 focus:ring-cyan-500/30" /> {isSearching && (
)}
{searchResults.length > 0 && (
    {searchResults.map((track) => ( void play(track.uri)} /> ))}
)}
{/* Disconnect. */}
); } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function ControlButton({ icon, onClick, title, large, }: { icon: string; onClick: () => void; title: string; large?: boolean; }) { return ( ); } function SearchResult({ track, onPlay, }: { track: SpotifyTrack; onPlay: () => void; }) { const art = track.album.images[track.album.images.length - 1]?.url ?? null; return (
  • {art && ( // eslint-disable-next-line @next/next/no-img-element {track.album.name} )}
    {track.name}
    {track.artists.map((a) => a.name).join(", ")} · {track.album.name}
  • ); }