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" ;
213import { useCallback , useMemo , useRef , useState } from "react" ;
314import {
415 buildEmbedUrl ,
@@ -40,17 +51,72 @@ export function SpotifyToggleButton() {
4051// ---------------------------------------------------------------------------
4152const 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// ---------------------------------------------------------------------------
46102export 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}
0 commit comments