Skip to content

Commit b4f773b

Browse files
authored
Add minimized Spotify player with persistent volume controls (#36)
- Add a minimize/restore state to the Spotify drawer and persist it - Keep volume controls and playback embed accessible in the header - Show the active playlist name in the header when available
1 parent c5b4ad3 commit b4f773b

2 files changed

Lines changed: 133 additions & 19 deletions

File tree

apps/web/src/components/SpotifyPlayer.tsx

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import { ChevronDownIcon, ChevronUpIcon, ListMusicIcon, Music2Icon, XIcon } from "lucide-react";
1+
import {
2+
ChevronDownIcon,
3+
ChevronUpIcon,
4+
ListMusicIcon,
5+
MaximizeIcon,
6+
MinimizeIcon,
7+
Music2Icon,
8+
Volume1Icon,
9+
Volume2Icon,
10+
VolumeXIcon,
11+
XIcon,
12+
} from "lucide-react";
213
import { useCallback, useMemo, useRef, useState } from "react";
314
import {
415
buildEmbedUrl,
@@ -40,17 +51,72 @@ export function SpotifyToggleButton() {
4051
// ---------------------------------------------------------------------------
4152
const CATEGORIES = [...new Set(DEFAULT_PLAYLISTS.map((p) => p.category))];
4253

54+
// ---------------------------------------------------------------------------
55+
// Volume slider — always accessible in the header bar
56+
// ---------------------------------------------------------------------------
57+
function VolumeControl() {
58+
const { volume, setVolume } = useSpotifyPlayerStore();
59+
const [premuteVolume, setPremuteVolume] = useState<number>(80);
60+
61+
const toggleMute = useCallback(() => {
62+
if (volume > 0) {
63+
setPremuteVolume(volume);
64+
setVolume(0);
65+
} else {
66+
setVolume(premuteVolume || 80);
67+
}
68+
}, [volume, premuteVolume, setVolume]);
69+
70+
const VolumeIcon = volume === 0 ? VolumeXIcon : volume < 50 ? Volume1Icon : Volume2Icon;
71+
72+
return (
73+
<div className="flex items-center gap-1.5">
74+
<button
75+
type="button"
76+
onClick={toggleMute}
77+
className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
78+
aria-label={volume === 0 ? "Unmute" : "Mute"}
79+
>
80+
<VolumeIcon className="size-3.5" />
81+
</button>
82+
<input
83+
type="range"
84+
min={0}
85+
max={100}
86+
step={1}
87+
value={volume}
88+
onChange={(e) => setVolume(Number(e.target.value))}
89+
className="spotify-volume-slider h-1 w-16 cursor-pointer appearance-none rounded-full bg-muted-foreground/20 accent-emerald-400 [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-emerald-400 [&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:hover:scale-125 [&::-moz-range-thumb]:size-2.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-emerald-400"
90+
aria-label="Volume"
91+
/>
92+
<span className="min-w-[2ch] text-[10px] tabular-nums text-muted-foreground/50">
93+
{volume}
94+
</span>
95+
</div>
96+
);
97+
}
98+
4399
// ---------------------------------------------------------------------------
44100
// Main Spotify Player Drawer — rendered at the bottom of ChatView
45101
// ---------------------------------------------------------------------------
46102
export function SpotifyPlayerDrawer() {
47-
const { isOpen, selectedPlaylistUri, customUri, setOpen, selectPlaylist, setCustomUri } =
48-
useSpotifyPlayerStore();
103+
const {
104+
isOpen,
105+
minimized,
106+
selectedPlaylistUri,
107+
customUri,
108+
setOpen,
109+
setMinimized,
110+
selectPlaylist,
111+
setCustomUri,
112+
} = useSpotifyPlayerStore();
49113
const [expanded, setExpanded] = useState(false);
50114
const [customInput, setCustomInput] = useState("");
51115
const [activeCategory, setActiveCategory] = useState<string>(CATEGORIES[0] ?? "Focus");
52116
const inputRef = useRef<HTMLInputElement>(null);
53117

118+
const activePlaylist = DEFAULT_PLAYLISTS.find((p) => p.uri === selectedPlaylistUri);
119+
54120
const embedUrl = useMemo(() => {
55121
// Custom URI takes priority
56122
if (customUri) {
@@ -81,24 +147,55 @@ export function SpotifyPlayerDrawer() {
81147

82148
return (
83149
<div className="border-t border-border/80 bg-background">
84-
{/* Header bar */}
150+
{/* Header bar — always visible */}
85151
<div className="flex items-center gap-2 px-3 py-1.5">
86-
<Music2Icon className="size-3.5 text-emerald-400" />
87-
<span className="text-xs font-medium text-foreground/80">Spotify</span>
152+
<Music2Icon className="size-3.5 shrink-0 text-emerald-400" />
153+
154+
{/* Now-playing label */}
155+
<span className="truncate text-xs font-medium text-foreground/80">
156+
{activePlaylist ? activePlaylist.name : "Spotify"}
157+
</span>
158+
159+
{/* Spacer */}
160+
<div className="flex-1" />
161+
162+
{/* Volume — always accessible */}
163+
<VolumeControl />
88164

165+
{/* Playlist picker toggle (only when not minimized) */}
166+
{!minimized && (
167+
<button
168+
type="button"
169+
onClick={() => setExpanded(!expanded)}
170+
className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
171+
aria-label={expanded ? "Collapse playlist picker" : "Expand playlist picker"}
172+
>
173+
{expanded ? (
174+
<ChevronDownIcon className="size-3.5" />
175+
) : (
176+
<ChevronUpIcon className="size-3.5" />
177+
)}
178+
</button>
179+
)}
180+
181+
{/* Minimize / Restore */}
89182
<button
90183
type="button"
91-
onClick={() => setExpanded(!expanded)}
92-
className="ml-auto rounded p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
93-
aria-label={expanded ? "Collapse playlist picker" : "Expand playlist picker"}
184+
onClick={() => {
185+
setMinimized(!minimized);
186+
if (!minimized) setExpanded(false);
187+
}}
188+
className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
189+
aria-label={minimized ? "Restore player" : "Minimize player"}
94190
>
95-
{expanded ? (
96-
<ChevronDownIcon className="size-3.5" />
191+
{minimized ? (
192+
<MaximizeIcon className="size-3.5" />
97193
) : (
98-
<ChevronUpIcon className="size-3.5" />
194+
<MinimizeIcon className="size-3.5" />
99195
)}
100196
</button>
101197

198+
{/* Close */}
102199
<button
103200
type="button"
104201
onClick={() => setOpen(false)}
@@ -109,8 +206,8 @@ export function SpotifyPlayerDrawer() {
109206
</button>
110207
</div>
111208

112-
{/* Expanded playlist picker */}
113-
{expanded && (
209+
{/* Expanded playlist picker (hidden when minimized) */}
210+
{expanded && !minimized && (
114211
<div className="border-t border-border/40 px-3 py-2">
115212
{/* Category tabs */}
116213
<div className="mb-2 flex gap-1 overflow-x-auto">
@@ -182,22 +279,30 @@ export function SpotifyPlayerDrawer() {
182279
</div>
183280
)}
184281

185-
{/* Spotify embed iframe */}
282+
{/* Spotify embed iframe — kept in DOM when minimized so audio continues */}
186283
{embedUrl ? (
187-
<div className="px-2 pb-2">
284+
<div
285+
className={cn(
286+
"transition-all duration-200",
287+
minimized
288+
? "pointer-events-none h-0 overflow-hidden opacity-0"
289+
: "px-2 pb-2",
290+
)}
291+
aria-hidden={minimized}
292+
>
188293
<iframe
189294
title="Spotify Player"
190295
src={embedUrl}
191296
width="100%"
192-
height={expanded ? "80" : "80"}
297+
height="80"
193298
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
194299
// eslint-disable-next-line react/iframe-missing-sandbox -- Spotify embed requires both allow-scripts and allow-same-origin to function
195300
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
196301
loading="lazy"
197302
className="rounded-xl border-0"
198303
/>
199304
</div>
200-
) : (
305+
) : !minimized ? (
201306
<div className="flex flex-col items-center gap-2 px-3 pb-3 pt-1">
202307
<p className="text-[11px] text-muted-foreground/50">
203308
Pick a playlist above or paste a Spotify link
@@ -211,7 +316,7 @@ export function SpotifyPlayerDrawer() {
211316
Browse Playlists
212317
</button>
213318
</div>
214-
)}
319+
) : null}
215320
</div>
216321
);
217322
}

apps/web/src/spotifyPlayerStore.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const DEFAULT_PLAYLISTS: SpotifyPlaylist[] = [
4242

4343
interface PersistedSpotifyState {
4444
isOpen: boolean;
45+
minimized: boolean;
4546
selectedPlaylistUri: string | null;
4647
customUri: string | null;
4748
volume: number;
@@ -50,6 +51,7 @@ interface PersistedSpotifyState {
5051
interface SpotifyPlayerStore extends PersistedSpotifyState {
5152
toggle: () => void;
5253
setOpen: (open: boolean) => void;
54+
setMinimized: (minimized: boolean) => void;
5355
selectPlaylist: (uri: string) => void;
5456
setCustomUri: (uri: string | null) => void;
5557
setVolume: (volume: number) => void;
@@ -60,6 +62,7 @@ const STORAGE_KEY = "okcode:spotify-player:v1";
6062
function readPersistedState(): PersistedSpotifyState {
6163
const defaults: PersistedSpotifyState = {
6264
isOpen: false,
65+
minimized: false,
6366
selectedPlaylistUri: null,
6467
customUri: null,
6568
volume: 80,
@@ -74,6 +77,7 @@ function readPersistedState(): PersistedSpotifyState {
7477
const parsed = JSON.parse(raw) as Partial<PersistedSpotifyState>;
7578
return {
7679
isOpen: typeof parsed.isOpen === "boolean" ? parsed.isOpen : false,
80+
minimized: typeof parsed.minimized === "boolean" ? parsed.minimized : false,
7781
selectedPlaylistUri:
7882
typeof parsed.selectedPlaylistUri === "string" ? parsed.selectedPlaylistUri : null,
7983
customUri: typeof parsed.customUri === "string" ? parsed.customUri : null,
@@ -112,6 +116,11 @@ export const useSpotifyPlayerStore = create<SpotifyPlayerStore>((set, get) => ({
112116
persistState({ ...get(), isOpen: open });
113117
},
114118

119+
setMinimized: (minimized) => {
120+
set({ minimized });
121+
persistState({ ...get(), minimized });
122+
},
123+
115124
selectPlaylist: (uri) => {
116125
set({ selectedPlaylistUri: uri, customUri: null });
117126
persistState({ ...get(), selectedPlaylistUri: uri, customUri: null });

0 commit comments

Comments
 (0)