diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index 1387cae..8bc2319 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -89,8 +89,12 @@ function MainApp() { const [queueMode, setQueueMode] = useState<'floating' | 'docked'>('floating'); const [queueDockedWidth, setQueueDockedWidth] = useState(380); const queueSidebarRef = useRef(null); + const shellRef = useRef(null); + const handleResizeWidthLive = useCallback((width: number) => { + shellRef.current?.style.setProperty('--docked-queue-w', `${width}px`); + }, []); - useEffect(() => { +useEffect(() => { window.sonos.getDisplayName().then(setDisplayName); window.sonos.getQueueMode().then(setQueueMode).catch(() => {}); window.sonos.getQueueDockedWidth().then(setQueueDockedWidth).catch(() => {}); @@ -99,7 +103,7 @@ function MainApp() { useEffect(() => { if (queueMode !== 'docked') return; function clamp() { - const max = Math.min(700, window.innerWidth - 320); + const max = window.innerWidth - (800 + 64); setQueueDockedWidth((w) => (w > max ? max : w)); } window.addEventListener('resize', clamp); @@ -436,7 +440,11 @@ function MainApp() { const splashReady = isAuthed && groups.length > 0 && !ytmLoading && !histLoading; return ( -
+
)}
diff --git a/renderer/src/components/PlayerBar.tsx b/renderer/src/components/PlayerBar.tsx index 3961cb7..c148bec 100644 --- a/renderer/src/components/PlayerBar.tsx +++ b/renderer/src/components/PlayerBar.tsx @@ -305,9 +305,11 @@ export function PlayerBar({ isAuthed, playback, onToggleQueue, onShuffle, queueM > - + {queueMode !== 'docked' && ( + + )} )} - + {queueMode !== 'docked' && ( + + )}
diff --git a/renderer/src/components/album/AlbumPanel.tsx b/renderer/src/components/album/AlbumPanel.tsx index 0aa5f28..b1b0812 100644 --- a/renderer/src/components/album/AlbumPanel.tsx +++ b/renderer/src/components/album/AlbumPanel.tsx @@ -52,7 +52,7 @@ export function AlbumPanel({ onAddToQueue, queueOpen }: Props) { const artist = data?.artist ?? ((item as Record)?.['subtitle'] as string) ?? ''; const artUrl = data?.artUrl ?? (item ? getItemArt(item) : null); const cachedArt = useImage(artUrl); - const dominantColor = useDominantColor(cachedArt); + const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); useEffect(() => { setSelected(new Set()); diff --git a/renderer/src/components/artist/ArtistHero.tsx b/renderer/src/components/artist/ArtistHero.tsx index ea43974..7bc36af 100644 --- a/renderer/src/components/artist/ArtistHero.tsx +++ b/renderer/src/components/artist/ArtistHero.tsx @@ -24,7 +24,7 @@ export function ArtistHero({ }); const cachedArt = useImage(getItemArt(artist)); - const dominantColor = useDominantColor(cachedArt); + const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); const name = (artist.title ?? artist.name ?? '') as string; const [selected, setSelected] = useState>(new Set()); diff --git a/renderer/src/components/artist/ArtistPanel.tsx b/renderer/src/components/artist/ArtistPanel.tsx index aed8a8c..eeefbd2 100644 --- a/renderer/src/components/artist/ArtistPanel.tsx +++ b/renderer/src/components/artist/ArtistPanel.tsx @@ -73,7 +73,7 @@ export function ArtistPanel({ onAddToQueue }: Props) { } const cachedArt = useImage(imageUrl); - const dominantColor = useDominantColor(cachedArt); + const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); const artistRadio = data?.playlists.find(p => (p.title as string)?.toLowerCase().includes('radio')); const latestAlbum = data?.albums[0] ?? null; diff --git a/renderer/src/components/queue/DraggableQueueRow.tsx b/renderer/src/components/queue/DraggableQueueRow.tsx index 73cb164..317400f 100644 --- a/renderer/src/components/queue/DraggableQueueRow.tsx +++ b/renderer/src/components/queue/DraggableQueueRow.tsx @@ -51,6 +51,11 @@ export function DraggableQueueRow({ ].filter(Boolean).join(' ')} data-playing={isPlaying ? 'true' : undefined} draggable + onPointerMove={e => { + const r = e.currentTarget.getBoundingClientRect(); + e.currentTarget.style.setProperty('--mx', `${e.clientX - r.left}px`); + e.currentTarget.style.setProperty('--my', `${e.clientY - r.top}px`); + }} onClick={e => onRowClick(index, e)} onDoubleClick={() => getActiveProvider().skipToTrack(index + 1)} onDragStart={e => onDragStart(index, e)} diff --git a/renderer/src/components/queue/QueueSidebar.tsx b/renderer/src/components/queue/QueueSidebar.tsx index 70528e7..aa30736 100644 --- a/renderer/src/components/queue/QueueSidebar.tsx +++ b/renderer/src/components/queue/QueueSidebar.tsx @@ -29,6 +29,7 @@ interface Props { onAddToQueue: (item: SonosItem, position: number) => void; dockedWidth?: number; onResizeWidth?: (width: number) => void; + onResizeWidthLive?: (width: number) => void; } export interface QueueSidebarHandle { @@ -36,8 +37,8 @@ export interface QueueSidebarHandle { } const MIN_DOCKED_WIDTH = 280; -const MAX_DOCKED_WIDTH = 700; -const MIN_ROUTES_WIDTH = 320; +const PLAYER_BAR_W = 800; // matches .inner width in PlayerBar.module.css +const PLAYER_BAR_PADDING = 64; // 32px breathing room each side export const QueueSidebar = forwardRef(function QueueSidebar( { @@ -58,6 +59,7 @@ export const QueueSidebar = forwardRef(function Queue onAddToQueue, dockedWidth, onResizeWidth, + onResizeWidthLive, }, ref ) { @@ -122,16 +124,16 @@ export const QueueSidebar = forwardRef(function Queue return () => clearTimeout(id); }, [isActive]); - // Track change while queue is open + // Track change while queue is visible useEffect(() => { - if (!open) return; + if (!isActive) return; const id = setTimeout(scrollToNowPlaying, 50); return () => clearTimeout(id); }, [currentQueueItemId]); // eslint-disable-line react-hooks/exhaustive-deps // Group switch — queue reloads async so use a longer delay useEffect(() => { - if (!open) return; + if (!isActive) return; const id = setTimeout(scrollToNowPlaying, 400); return () => clearTimeout(id); }, [groupName]); // eslint-disable-line react-hooks/exhaustive-deps @@ -160,11 +162,12 @@ export const QueueSidebar = forwardRef(function Queue handle.setPointerCapture(e.pointerId); document.documentElement.classList.add('resizingQueue'); - const max = () => Math.min(MAX_DOCKED_WIDTH, window.innerWidth - MIN_ROUTES_WIDTH); + const max = () => window.innerWidth - (PLAYER_BAR_W + PLAYER_BAR_PADDING); const onMove = (ev: PointerEvent) => { const next = Math.max(MIN_DOCKED_WIDTH, Math.min(max(), startWidth + (startX - ev.clientX))); setLiveWidth(next); + onResizeWidthLive?.(next); }; const onUp = () => { document.documentElement.classList.remove('resizingQueue'); @@ -283,6 +286,7 @@ export const QueueSidebar = forwardRef(function Queue
e.currentTarget.style.setProperty('--mouse-y', `${e.nativeEvent.offsetY}px`)} role="separator" aria-orientation="vertical" aria-label="Resize queue" @@ -290,7 +294,9 @@ export const QueueSidebar = forwardRef(function Queue )} {isDocked && (
- +
+ +
)}
@@ -325,7 +331,7 @@ export const QueueSidebar = forwardRef(function Queue
{ e.preventDefault(); e.dataTransfer.dropEffect = e.dataTransfer.types.includes('application/sonos-item-list') ? 'copy' : 'move'; diff --git a/renderer/src/hooks/useDominantColor.ts b/renderer/src/hooks/useDominantColor.ts index 5b1785c..16c7931 100644 --- a/renderer/src/hooks/useDominantColor.ts +++ b/renderer/src/hooks/useDominantColor.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -export function useDominantColor(src: string | null): string | null { +export function useDominantColor(src: string | null, { setGlobal = false } = {}): string | null { const [color, setColor] = useState(null); useEffect(() => { @@ -38,5 +38,15 @@ export function useDominantColor(src: string | null): string | null { img.src = src; }, [src]); + useEffect(() => { + if (!setGlobal) return; + if (color) { + document.documentElement.style.setProperty('--panel-color', color); + } else { + document.documentElement.style.removeProperty('--panel-color'); + } + return () => { document.documentElement.style.removeProperty('--panel-color'); }; + }, [color, setGlobal]); + return color; } diff --git a/renderer/src/styles/App.module.css b/renderer/src/styles/App.module.css index 7c48b1b..1d48ed1 100644 --- a/renderer/src/styles/App.module.css +++ b/renderer/src/styles/App.module.css @@ -4,9 +4,9 @@ flex-direction: column; overflow: hidden; background: - radial-gradient(ellipse at 20% 50%, rgba(70, 45, 110, 0.45) 0%, transparent 60%), - radial-gradient(ellipse at 80% 15%, rgba(25, 55, 95, 0.35) 0%, transparent 55%), - radial-gradient(ellipse at 55% 85%, rgba(20, 55, 85, 0.3) 0%, transparent 50%), + radial-gradient(ellipse at 20% 50%, rgba(90, 50, 150, 0.7) 0%, transparent 60%), + radial-gradient(ellipse at 80% 15%, rgba(25, 70, 130, 0.55) 0%, transparent 55%), + radial-gradient(ellipse at 55% 85%, rgba(20, 65, 110, 0.45) 0%, transparent 50%), #0f0f14; } @@ -15,6 +15,14 @@ min-height: 0; overflow: hidden; display: flex; + position: relative; + background: linear-gradient( + 180deg, + rgba(var(--panel-color, 80, 60, 120), 0.55) 0%, + rgba(var(--panel-color, 80, 60, 120), 0.2) 20%, + transparent 40% + ); + transition: background 0.8s ease; } .toast { diff --git a/renderer/src/styles/PlayerBar.module.css b/renderer/src/styles/PlayerBar.module.css index 710077f..1b2d606 100644 --- a/renderer/src/styles/PlayerBar.module.css +++ b/renderer/src/styles/PlayerBar.module.css @@ -2,7 +2,7 @@ .bar { position: fixed; bottom: 24px; - left: 50%; + left: calc((100vw - var(--docked-queue-w, 0px)) / 2); transform: translateX(-50%) translateZ(0); z-index: 150; border-radius: 22px; diff --git a/renderer/src/styles/QueueSidebar.module.css b/renderer/src/styles/QueueSidebar.module.css index dbb0068..1b7f72a 100644 --- a/renderer/src/styles/QueueSidebar.module.css +++ b/renderer/src/styles/QueueSidebar.module.css @@ -42,12 +42,12 @@ margin-left: auto; border-radius: 0; border: none; - border-left: 1px solid var(--border); box-shadow: none; backdrop-filter: none; - background: var(--bg-1); + background: transparent; transform: none; transition: none; + overflow: visible; /* Sit above the TopNav drag region (z-index 199) so the buttons below are clickable; the dockedTopBar provides its own drag region. */ z-index: 200; @@ -60,22 +60,50 @@ align-items: center; justify-content: flex-end; height: var(--nav-h); - padding: 0 8px; + padding: 0 12px; flex-shrink: 0; -webkit-app-region: drag; } +.dockedTopBar .winPill { + display: flex; + align-items: center; + gap: 2px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; + padding: 3px 4px; + -webkit-app-region: no-drag; +} + .resizeHandle { position: absolute; top: 0; left: 0; bottom: 0; - width: 6px; + width: 16px; cursor: ew-resize; background: transparent; z-index: 2; touch-action: none; -webkit-app-region: no-drag; } -.resizeHandle:hover { background: rgba(255, 255, 255, 0.08); } +.resizeHandle::after { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 280px; + background: radial-gradient( + ellipse 100% 300px at 0px var(--mouse-y, 50%), + rgba(200, 160, 255, 0.18) 0%, + rgba(160, 110, 255, 0.08) 50%, + transparent 80% + ); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} +.resizeHandle:hover::after { opacity: 1; } :global(html.resizingQueue), :global(html.resizingQueue) * { @@ -93,6 +121,14 @@ flex-shrink: 0; } +.sidebar.docked .header { + order: 99; + border-bottom: none; + border-top: 1px solid var(--border); + padding: 12px 16px; +} + + .title { font-size: 14px; font-weight: 600; @@ -161,6 +197,26 @@ padding: 6px 6px 24px; } +.content::-webkit-scrollbar { + width: 10px; +} +.content::-webkit-scrollbar-track { + background: transparent; + margin-top: 20px; /* never touch top */ + margin-bottom: 8px; +} +.content::-webkit-scrollbar-thumb { + /* transparent border shrinks visual width while keeping 16px hit box */ + border: 2px solid transparent; + background-clip: padding-box; + background-color: rgba(255, 255, 255, 0.18); + border-radius: 99px; + max-height: 300px; +} +.content::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.32); +} + /* ── Track row ── */ .row { display: flex; @@ -170,8 +226,25 @@ border-radius: var(--r-sm); transition: background 0.1s; cursor: default; + position: relative; + overflow: hidden; +} +.row::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + ellipse 60% 120% at var(--mx, 50%) var(--my, 50%), + rgba(190, 150, 255, 0.13) 0%, + rgba(150, 100, 255, 0.05) 50%, + transparent 75% + ); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; } -.row:hover { background: var(--bg-2); } +.row:hover::before { opacity: 1; } +.row:hover { background: rgba(255, 255, 255, 0.03); } .row.selected { background: rgba(255,255,255,0.1); } .row.selected:hover { background: rgba(255,255,255,0.13); } .row.playing .name { color: #fff; font-weight: 600; } @@ -241,11 +314,13 @@ .subAlbum { display: block; + width: fit-content; + max-width: 100%; background: none; border: none; padding: 0; font-size: 11px; font-family: inherit; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - width: 100%; text-align: left; + text-align: left; cursor: pointer; transition: color 0.12s; } .subAlbum:hover { color: var(--text); text-decoration: underline; } diff --git a/renderer/src/styles/TopNav.module.css b/renderer/src/styles/TopNav.module.css index 0d9b4e2..1614dff 100644 --- a/renderer/src/styles/TopNav.module.css +++ b/renderer/src/styles/TopNav.module.css @@ -13,7 +13,7 @@ .navRoot { position: fixed; top: 12px; - left: 50%; + left: calc((100vw - var(--docked-queue-w, 0px)) / 2); transform: translateX(-50%); z-index: 200; display: flex; diff --git a/renderer/src/styles/global.css b/renderer/src/styles/global.css index 9a8de38..faf5f3c 100644 --- a/renderer/src/styles/global.css +++ b/renderer/src/styles/global.css @@ -26,25 +26,25 @@ user-select: none; } -input[type="text"], +input[type='text'], input:not([type]), textarea { user-select: text; } @font-face { - font-family: "San Francisco"; + font-family: 'San Francisco'; font-weight: 400; - src: url("https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-regular-webfont.woff"); + src: url('https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-regular-webfont.woff'); } body { font-family: - "San Francisco", + 'San Francisco', -apple-system, BlinkMacSystemFont, - "SF Pro Text", - "Segoe UI", + 'SF Pro Text', + 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); @@ -76,16 +76,21 @@ html[data-mini] #root { } ::-webkit-scrollbar { - width: 4px; - height: 4px; + width: 10px; + height: 10px; + background: transparent; } ::-webkit-scrollbar-track { background: transparent; + margin-top: 20px; + margin-bottom: 8px; } ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 99px; } ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); + background-color: rgba(255, 255, 255, 0.22); } diff --git a/src/main.ts b/src/main.ts index 5acf7ea..70c04c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1078,8 +1078,10 @@ function onAuthReady(): void { function createUIWindow(): void { uiWin = new BrowserWindow({ - width: 960, - height: 640, + width: 1280, + height: 720, + minWidth: 864 + 280, // player bar (800) + 32px each side + min queue width + minHeight: 480, title: `True-Tunes v${app.getVersion()}`, backgroundColor: '#1c1c1e', frame: false,