Skip to content

Commit 3842cfb

Browse files
authored
Merge pull request #17 from OpenKnots/okcode/spotify-player-integration
Add Spotify player drawer to the web UI
2 parents 5b1e6f7 + c1db3a8 commit 3842cfb

4 files changed

Lines changed: 394 additions & 0 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import BranchToolbar from "./BranchToolbar";
9090
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
9191
import PlanSidebar from "./PlanSidebar";
9292
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
93+
import { SpotifyPlayerDrawer } from "./SpotifyPlayer";
9394
import {
9495
BotIcon,
9596
ChevronDownIcon,
@@ -4648,6 +4649,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
46484649
);
46494650
})()}
46504651

4652+
<SpotifyPlayerDrawer />
4653+
46514654
{expandedImage && expandedImageItem && (
46524655
<div
46534656
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]"

apps/web/src/components/Sidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
TerminalIcon,
1212
TriangleAlertIcon,
1313
} from "lucide-react";
14+
import { SpotifyToggleButton } from "./SpotifyPlayer";
1415
import { autoAnimate } from "@formkit/auto-animate";
1516
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
1617
import {
@@ -1882,6 +1883,9 @@ export default function Sidebar() {
18821883
<SidebarSeparator />
18831884
<SidebarFooter className="p-2">
18841885
<SidebarMenu>
1886+
<SidebarMenuItem>
1887+
<SpotifyToggleButton />
1888+
</SidebarMenuItem>
18851889
<SidebarMenuItem>
18861890
{isOnSettings ? (
18871891
<SidebarMenuButton
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { ChevronDownIcon, ChevronUpIcon, ListMusicIcon, Music2Icon, XIcon } from "lucide-react";
2+
import { useCallback, useMemo, useRef, useState } from "react";
3+
import {
4+
buildEmbedUrl,
5+
DEFAULT_PLAYLISTS,
6+
parseSpotifyUri,
7+
useSpotifyPlayerStore,
8+
} from "../spotifyPlayerStore";
9+
import { cn } from "~/lib/utils";
10+
11+
// ---------------------------------------------------------------------------
12+
// Compact mini-bar shown at the bottom of the sidebar
13+
// ---------------------------------------------------------------------------
14+
export function SpotifyToggleButton() {
15+
const { isOpen, toggle, selectedPlaylistUri } = useSpotifyPlayerStore();
16+
const activePlaylist = DEFAULT_PLAYLISTS.find((p) => p.uri === selectedPlaylistUri);
17+
18+
return (
19+
<button
20+
type="button"
21+
onClick={toggle}
22+
className={cn(
23+
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
24+
isOpen
25+
? "bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20"
26+
: "text-muted-foreground/70 hover:bg-accent hover:text-foreground",
27+
)}
28+
>
29+
<Music2Icon className="size-3.5" />
30+
<span className="truncate">
31+
{isOpen && activePlaylist ? activePlaylist.name : "Spotify"}
32+
</span>
33+
{isOpen && (
34+
<span className="ml-auto flex size-1.5 rounded-full bg-emerald-400 animate-pulse" />
35+
)}
36+
</button>
37+
);
38+
}
39+
40+
// ---------------------------------------------------------------------------
41+
// Categories for the playlist picker
42+
// ---------------------------------------------------------------------------
43+
const CATEGORIES = [...new Set(DEFAULT_PLAYLISTS.map((p) => p.category))];
44+
45+
// ---------------------------------------------------------------------------
46+
// Main Spotify Player Drawer — rendered at the bottom of ChatView
47+
// ---------------------------------------------------------------------------
48+
export function SpotifyPlayerDrawer() {
49+
const { isOpen, selectedPlaylistUri, customUri, setOpen, selectPlaylist, setCustomUri } =
50+
useSpotifyPlayerStore();
51+
const [expanded, setExpanded] = useState(false);
52+
const [customInput, setCustomInput] = useState("");
53+
const [activeCategory, setActiveCategory] = useState<string>(CATEGORIES[0] ?? "Focus");
54+
const inputRef = useRef<HTMLInputElement>(null);
55+
56+
const embedUrl = useMemo(() => {
57+
// Custom URI takes priority
58+
if (customUri) {
59+
const parsed = parseSpotifyUri(customUri);
60+
if (parsed) return buildEmbedUrl(parsed.type, parsed.id);
61+
}
62+
// Selected preset playlist
63+
if (selectedPlaylistUri) {
64+
return buildEmbedUrl("playlist", selectedPlaylistUri);
65+
}
66+
return null;
67+
}, [customUri, selectedPlaylistUri]);
68+
69+
const handleCustomSubmit = useCallback(() => {
70+
const trimmed = customInput.trim();
71+
if (!trimmed) return;
72+
const parsed = parseSpotifyUri(trimmed);
73+
if (parsed) {
74+
setCustomUri(trimmed);
75+
setCustomInput("");
76+
setExpanded(false);
77+
}
78+
}, [customInput, setCustomUri]);
79+
80+
if (!isOpen) return null;
81+
82+
const filteredPlaylists = DEFAULT_PLAYLISTS.filter((p) => p.category === activeCategory);
83+
84+
return (
85+
<div className="border-t border-border/80 bg-background">
86+
{/* Header bar */}
87+
<div className="flex items-center gap-2 px-3 py-1.5">
88+
<Music2Icon className="size-3.5 text-emerald-400" />
89+
<span className="text-xs font-medium text-foreground/80">Spotify</span>
90+
91+
<button
92+
type="button"
93+
onClick={() => setExpanded(!expanded)}
94+
className="ml-auto rounded p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
95+
aria-label={expanded ? "Collapse playlist picker" : "Expand playlist picker"}
96+
>
97+
{expanded ? (
98+
<ChevronDownIcon className="size-3.5" />
99+
) : (
100+
<ChevronUpIcon className="size-3.5" />
101+
)}
102+
</button>
103+
104+
<button
105+
type="button"
106+
onClick={() => setOpen(false)}
107+
className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
108+
aria-label="Close Spotify player"
109+
>
110+
<XIcon className="size-3.5" />
111+
</button>
112+
</div>
113+
114+
{/* Expanded playlist picker */}
115+
{expanded && (
116+
<div className="border-t border-border/40 px-3 py-2">
117+
{/* Category tabs */}
118+
<div className="mb-2 flex gap-1 overflow-x-auto">
119+
{CATEGORIES.map((cat) => (
120+
<button
121+
key={cat}
122+
type="button"
123+
onClick={() => setActiveCategory(cat)}
124+
className={cn(
125+
"shrink-0 rounded-full px-2.5 py-0.5 text-[11px] font-medium transition-colors",
126+
activeCategory === cat
127+
? "bg-emerald-500/20 text-emerald-400"
128+
: "text-muted-foreground/60 hover:bg-accent hover:text-foreground",
129+
)}
130+
>
131+
{cat}
132+
</button>
133+
))}
134+
</div>
135+
136+
{/* Playlist grid */}
137+
<div className="grid max-h-32 grid-cols-2 gap-1 overflow-y-auto pr-1">
138+
{filteredPlaylists.map((playlist) => (
139+
<button
140+
key={playlist.uri}
141+
type="button"
142+
onClick={() => {
143+
selectPlaylist(playlist.uri);
144+
setExpanded(false);
145+
}}
146+
className={cn(
147+
"flex items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] transition-colors",
148+
selectedPlaylistUri === playlist.uri && !customUri
149+
? "bg-emerald-500/15 text-emerald-400"
150+
: "text-muted-foreground/70 hover:bg-accent hover:text-foreground",
151+
)}
152+
>
153+
<ListMusicIcon className="size-3 shrink-0" />
154+
<span className="truncate">{playlist.name}</span>
155+
</button>
156+
))}
157+
</div>
158+
159+
{/* Custom URL input */}
160+
<div className="mt-2 flex gap-1.5">
161+
<input
162+
ref={inputRef}
163+
type="text"
164+
value={customInput}
165+
onChange={(e) => setCustomInput(e.target.value)}
166+
onKeyDown={(e) => {
167+
if (e.key === "Enter") {
168+
e.preventDefault();
169+
handleCustomSubmit();
170+
}
171+
}}
172+
placeholder="Paste Spotify link..."
173+
className="flex-1 rounded-md border border-border/60 bg-background px-2 py-1 text-[11px] text-foreground placeholder:text-muted-foreground/40 focus:border-emerald-500/50 focus:outline-none"
174+
/>
175+
<button
176+
type="button"
177+
onClick={handleCustomSubmit}
178+
disabled={!customInput.trim()}
179+
className="shrink-0 rounded-md bg-emerald-500/20 px-2 py-1 text-[11px] font-medium text-emerald-400 transition-colors hover:bg-emerald-500/30 disabled:opacity-40"
180+
>
181+
Play
182+
</button>
183+
</div>
184+
</div>
185+
)}
186+
187+
{/* Spotify embed iframe */}
188+
{embedUrl ? (
189+
<div className="px-2 pb-2">
190+
<iframe
191+
title="Spotify Player"
192+
src={embedUrl}
193+
width="100%"
194+
height={expanded ? "80" : "80"}
195+
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
196+
// eslint-disable-next-line react/iframe-missing-sandbox -- Spotify embed requires both allow-scripts and allow-same-origin to function
197+
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
198+
loading="lazy"
199+
className="rounded-xl border-0"
200+
/>
201+
</div>
202+
) : (
203+
<div className="flex flex-col items-center gap-2 px-3 pb-3 pt-1">
204+
<p className="text-[11px] text-muted-foreground/50">
205+
Pick a playlist above or paste a Spotify link
206+
</p>
207+
<button
208+
type="button"
209+
onClick={() => setExpanded(true)}
210+
className="rounded-md bg-emerald-500/15 px-3 py-1.5 text-xs font-medium text-emerald-400 transition-colors hover:bg-emerald-500/25"
211+
>
212+
<ListMusicIcon className="mr-1.5 inline size-3.5" />
213+
Browse Playlists
214+
</button>
215+
</div>
216+
)}
217+
</div>
218+
);
219+
}

0 commit comments

Comments
 (0)