feat: add SOUNDCLAW jukebox skill integration (#67)

Add the office jukebox flow so Spotify can be controlled from the SOUNDCLAW skill, manual jukebox UI, and local browser auth bridge during development.

Made-with: Cursor
This commit is contained in:
Luke The Dev
2026-03-26 18:35:19 -05:00
committed by GitHub
parent a202cdc80f
commit 3da1694085
27 changed files with 3471 additions and 983 deletions
@@ -0,0 +1,463 @@
"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 (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm">
<div
className="w-full max-w-2xl overflow-hidden rounded-3xl border border-cyan-500/20 bg-slate-950/98 shadow-2xl"
style={{ maxHeight: "90vh" }}
>
{/* Header. */}
<div className="flex items-center justify-between border-b border-white/5 px-6 py-4">
<div className="flex items-center gap-3">
<span className="text-2xl">🎵</span>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan-400/70">
Soundclaw
</div>
<h2 className="text-base font-semibold text-white">Office Jukebox</h2>
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 px-4 py-1.5 text-sm text-slate-400 transition hover:bg-white/5 hover:text-white"
>
Close
</button>
</div>
{/* Body. */}
<div className="overflow-y-auto" style={{ maxHeight: "calc(90vh - 68px)" }}>
{view === "setup" ? <SetupView /> : <PlayerView />}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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("<p style=\"font-family: sans-serif; padding: 24px;\">Redirecting to Spotify...</p>");
await startSpotifyAuth(inputId.trim(), redirectUri, popup);
setIsRedirecting(false);
};
return (
<div className="space-y-6 p-6">
<div className="rounded-2xl border border-cyan-500/20 bg-cyan-500/5 px-4 py-3 text-sm text-cyan-100">
Keep Claw3D open on <code className="rounded bg-slate-900/70 px-1">{localhostOrigin}</code>.
Spotify will redirect to your ngrok callback, which sends the auth code back into this window.
</div>
{!callbackLooksValid && callbackBaseUrl.trim().length > 0 && (
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
Enter a valid HTTPS ngrok URL, for example <code className="rounded bg-slate-900/70 px-1">https://your-id.ngrok-free.app</code>.
</div>
)}
{/* What you need card. */}
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-5">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-amber-300">
<span></span> What you need before connecting
</h3>
<ol className="space-y-3 text-sm text-slate-300">
<li className="flex gap-2">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-xs font-bold text-amber-300">1</span>
<span>
Go to{" "}
<a
href="https://developer.spotify.com/dashboard"
target="_blank"
rel="noreferrer"
className="text-cyan-400 underline underline-offset-2 hover:text-cyan-300"
>
developer.spotify.com/dashboard
</a>{" "}
and create an app (or use an existing one).
</span>
</li>
<li className="flex gap-2">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-xs font-bold text-amber-300">2</span>
<span>
In your Spotify app settings, add this <strong className="text-white">Redirect URI</strong>:
</span>
</li>
{redirectUri && (
<li className="ml-7">
<code className="block w-full rounded-lg border border-cyan-500/20 bg-slate-900 px-3 py-2 font-mono text-xs text-cyan-300 break-all">
{redirectUri}
</code>
<button
type="button"
onClick={() => navigator.clipboard.writeText(redirectUri)}
className="mt-1.5 text-xs text-slate-500 hover:text-slate-300"
>
Copy to clipboard
</button>
</li>
)}
<li className="flex gap-2">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-xs font-bold text-amber-300">3</span>
<span>Paste your public <strong className="text-white">ngrok URL</strong> below, then use the exact redirect shown here in Spotify.</span>
</li>
<li className="flex gap-2">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-xs font-bold text-amber-300">4</span>
<span>Keep this local office tab open while authenticating. The popup callback will hand the code back to this page.</span>
</li>
<li className="flex gap-2">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-xs font-bold text-amber-300">5</span>
<span>Make sure Spotify is open and playing on at least one device before using playback controls.</span>
</li>
</ol>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-300">
ngrok Public URL
</label>
<input
type="url"
value={callbackBaseUrl}
onChange={(e) => 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"
/>
<p className="text-xs text-slate-500">
This is only used for the Spotify OAuth callback bridge. Your app can stay on {localhostOrigin}.
</p>
</div>
{/* Client ID input. */}
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-300">
Spotify Client ID
</label>
<input
type="text"
value={inputId}
onChange={(e) => 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"
/>
<p className="text-xs text-slate-500">
Stored locally in your browser. Never sent to any server other than Spotify.
</p>
</div>
<button
type="button"
disabled={!inputId.trim() || !redirectUri || !callbackLooksValid || isRedirecting}
onClick={handleConnect}
className="w-full rounded-xl bg-[#1DB954] py-3 text-sm font-semibold text-black transition hover:bg-[#1ed760] active:scale-[.98] disabled:cursor-not-allowed disabled:opacity-40"
>
{isRedirecting ? "Opening Spotify…" : "Connect with Spotify"}
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<ReturnType<typeof setTimeout> | 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 (
<div className="flex flex-col gap-4 p-6">
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* Now playing. */}
<div className="rounded-2xl border border-white/5 bg-slate-900/60 p-4">
<div className="mb-3 font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500">
Now playing
</div>
{isLoadingPlayer && !track ? (
<div className="flex items-center gap-3 text-slate-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-slate-600 border-t-cyan-500" />
<span className="text-sm">Loading player</span>
</div>
) : track ? (
<div className="flex items-center gap-4">
{albumArt && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={albumArt}
alt={track.album.name}
className="h-14 w-14 shrink-0 rounded-lg object-cover shadow-lg"
/>
)}
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-white">{track.name}</div>
<div className="truncate text-sm text-slate-400">
{track.artists.map((a) => a.name).join(", ")}
</div>
<div className="truncate text-xs text-slate-600">{track.album.name}</div>
</div>
</div>
) : (
<p className="text-sm text-slate-500">
No active playback. Open Spotify on a device first, then hit play.
</p>
)}
{/* Transport controls. */}
<div className="mt-4 flex items-center justify-center gap-4">
<ControlButton icon="⏮" onClick={() => void previous()} title="Previous" />
{playerState?.isPlaying ? (
<ControlButton icon="⏸" onClick={() => void pause()} title="Pause" large />
) : (
<ControlButton icon="▶" onClick={() => void resume()} title="Play" large />
)}
<ControlButton icon="⏭" onClick={() => void next()} title="Next" />
</div>
{/* Volume. */}
{playerState && (
<div className="mt-4 flex items-center gap-3">
<span className="text-sm text-slate-500">🔈</span>
<input
type="range"
min={0}
max={100}
value={playerState.volumePercent}
onChange={(e) => void volume(Number(e.target.value))}
className="h-1.5 w-full cursor-pointer accent-cyan-400"
/>
<span className="w-8 text-right font-mono text-xs text-slate-500">
{playerState.volumePercent}%
</span>
</div>
)}
</div>
{/* Search. */}
<div>
<div className="mb-2 font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500">
Search tracks
</div>
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-slate-600 border-t-cyan-500" />
</div>
)}
</div>
{searchResults.length > 0 && (
<ul className="mt-2 divide-y divide-white/5 overflow-hidden rounded-xl border border-white/5 bg-slate-900/60">
{searchResults.map((track) => (
<SearchResult key={track.id} track={track} onPlay={() => void play(track.uri)} />
))}
</ul>
)}
</div>
{/* Disconnect. */}
<div className="pt-2 text-center">
<button
type="button"
onClick={disconnect}
className="text-xs text-slate-600 underline underline-offset-2 hover:text-slate-400"
>
Disconnect Spotify
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function ControlButton({
icon,
onClick,
title,
large,
}: {
icon: string;
onClick: () => void;
title: string;
large?: boolean;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className={`flex items-center justify-center rounded-full border border-white/10 text-white transition hover:bg-white/10 active:scale-95 ${
large ? "h-11 w-11 text-lg" : "h-9 w-9 text-sm"
}`}
>
{icon}
</button>
);
}
function SearchResult({
track,
onPlay,
}: {
track: SpotifyTrack;
onPlay: () => void;
}) {
const art = track.album.images[track.album.images.length - 1]?.url ?? null;
return (
<li className="flex items-center gap-3 px-4 py-3 transition hover:bg-white/5">
{art && (
// eslint-disable-next-line @next/next/no-img-element
<img src={art} alt={track.album.name} className="h-9 w-9 shrink-0 rounded object-cover" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{track.name}</div>
<div className="truncate text-xs text-slate-400">
{track.artists.map((a) => a.name).join(", ")} · {track.album.name}
</div>
</div>
<button
type="button"
onClick={onPlay}
className="shrink-0 rounded-full border border-cyan-500/30 px-3 py-1 text-xs font-medium text-cyan-400 transition hover:bg-cyan-500/10"
>
Play
</button>
</li>
);
}