"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. */}
{/* Body. */}
);
}
// ---------------------------------------------------------------------------
// 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
Go to{" "}
developer.spotify.com/dashboard
{" "}
and create an app (or use an existing one).
-
2
In your Spotify app settings, add this Redirect URI:
{redirectUri && (
-
{redirectUri}
)}
-
3
Paste your public ngrok URL below, then use the exact redirect shown here in Spotify.
-
4
Keep this local office tab open while authenticating. The popup callback will hand the code back to this page.
-
5
Make sure Spotify is open and playing on at least one device before using playback controls.
{/* Client ID input. */}
);
}
// ---------------------------------------------------------------------------
// 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 ? (
) : track ? (
{albumArt && (
// eslint-disable-next-line @next/next/no-img-element

)}
{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
{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.name}
{track.artists.map((a) => a.name).join(", ")} · {track.album.name}
);
}