From ac17f5c37b1615c0b2ce61c374b114e803435fc3 Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:10:51 +0100 Subject: [PATCH 01/13] Refactor: Queuedle game item overlap logic --- server/src/shared/gameGenerator.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/server/src/shared/gameGenerator.ts b/server/src/shared/gameGenerator.ts index 57a76f2..96a790f 100644 --- a/server/src/shared/gameGenerator.ts +++ b/server/src/shared/gameGenerator.ts @@ -183,11 +183,10 @@ function buildBonus( } function itemsOverlap(a: GameItem, b: GameItem): boolean { - if (a.category === b.category && a.id === b.id) return true; + if (a.category !== b.category) return true; + if (a.id === b.id) return true; if (a.artistKey && b.artistKey && a.artistKey === b.artistKey) { - if (a.category === 'artist' || b.category === 'artist') return true; - if (a.category === 'album' && b.category === 'track') return true; - if (a.category === 'track' && b.category === 'album') return true; + if (a.category === 'artist') return true; } if (a.albumKey && b.albumKey && a.albumKey === b.albumKey) { return true; From fc30210cf9e07015a85cc37a33b61936f65d19eb Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:10:52 +0100 Subject: [PATCH 02/13] Feat: Add 'select album' button to queue sidebar --- .../src/components/queue/QueueSidebar.tsx | 58 ++++++++++++++++--- .../src/lib/__tests__/queueHelpers.test.ts | 51 +++++++++++++++- renderer/src/lib/queueHelpers.ts | 30 ++++++++++ renderer/src/styles/QueueSidebar.module.css | 20 +++++++ 4 files changed, 149 insertions(+), 10 deletions(-) diff --git a/renderer/src/components/queue/QueueSidebar.tsx b/renderer/src/components/queue/QueueSidebar.tsx index fa481a2..e17d302 100644 --- a/renderer/src/components/queue/QueueSidebar.tsx +++ b/renderer/src/components/queue/QueueSidebar.tsx @@ -1,7 +1,7 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState, Fragment, forwardRef } from 'react'; import { useQueries } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; -import { applyReorderLocally } from '../../lib/queueHelpers'; +import { applyReorderLocally, expandToAlbumBlock } from '../../lib/queueHelpers'; import { createDragGhost } from '../../lib/dragHelpers'; import { getActiveProvider } from '../../providers'; import { useAttribution } from '../../hooks/useAttribution'; @@ -79,6 +79,19 @@ export const QueueSidebar = forwardRef(function Queue })), }); + // Album ids per item, resolved from useTrackDetails (the same data the queue row + // uses to render the album link) with the raw NormalizedTrack value as fallback. + // Sonos rarely embeds album info on raw queue rows so the resolved value is what + // the "Select album" button must match against. + const resolvedAlbumIds = useMemo<(string | null)[]>( + () => + items.map((item, i) => { + const fromDetails = trackDetailsResults[i]?.data?.albumId; + return (fromDetails ?? item.track.albumId) || null; + }), + [items, trackDetailsResults], + ); + const [nowMs, setNowMs] = useState(0); useEffect(() => { setNowMs(Date.now()); }, [positionMs]); @@ -346,14 +359,41 @@ onClick={handleContentClick} {selCount > 0 && (
{selCount} track{selCount !== 1 ? 's' : ''} selected - +
+ {selCount === 1 && (() => { + const anchor = [...selected][0]; + const anchorTrack = items[anchor]?.track; + if (!anchorTrack) return null; + const block = expandToAlbumBlock(items.length, anchor, resolvedAlbumIds); + // Hide the button when there's nothing to expand to — keeps the bar + // from offering an action that would be a no-op (or that grabbed the + // whole queue back when this had an artist fallback). + if (block.size <= 1) return null; + const resolvedAlbumName = + trackDetailsResults[anchor]?.data?.albumName ?? anchorTrack.albumName; + const tooltip = `Expand to all ${block.size} tracks from ${resolvedAlbumName ?? 'this album'} in sequence`; + return ( + + ); + })()} + +
)} diff --git a/renderer/src/lib/__tests__/queueHelpers.test.ts b/renderer/src/lib/__tests__/queueHelpers.test.ts index ae94b13..baa6f18 100644 --- a/renderer/src/lib/__tests__/queueHelpers.test.ts +++ b/renderer/src/lib/__tests__/queueHelpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { applyReorderLocally } from '../queueHelpers'; +import { applyReorderLocally, expandToAlbumBlock } from '../queueHelpers'; // Using plain strings — applyReorderLocally is generic, no QueueItem needed here. const items = ['A', 'B', 'C', 'D', 'E']; @@ -75,3 +75,52 @@ describe('applyReorderLocally', () => { expect(applyReorderLocally(['A', 'B', 'C'], [0], 3)).toEqual(['B', 'C', 'A']); }); }); + +describe('expandToAlbumBlock', () => { + it('selects a contiguous run of tracks sharing an albumId', () => { + const albumIds = ['A', 'A', 'A', 'B']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)].sort((a, b) => a - b)).toEqual([0, 1, 2]); + }); + + it('stops at the boundary of a different album', () => { + const albumIds = ['A', 'B', 'B', 'A']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)].sort((a, b) => a - b)).toEqual([1, 2]); + }); + + it('returns just the anchor when anchor has no albumId (no artist fallback)', () => { + const albumIds = [null, null, null]; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); + + it('returns just the anchor when an empty-string albumId is passed', () => { + const albumIds = ['', '', '']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); + + it('returns just the anchor when neighbours have different albums', () => { + const albumIds = ['A', 'B', 'C']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)]).toEqual([1]); + }); + + it('handles anchor at the start of the list', () => { + const albumIds = ['A', 'A', 'B']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)].sort((a, b) => a - b)).toEqual([0, 1]); + }); + + it('handles anchor at the end of the list', () => { + const albumIds = ['B', 'A', 'A']; + expect([...expandToAlbumBlock(albumIds.length, 2, albumIds)].sort((a, b) => a - b)).toEqual([1, 2]); + }); + + it('returns just the anchor when anchor is out of bounds', () => { + const albumIds = ['A']; + expect([...expandToAlbumBlock(albumIds.length, 5, albumIds)]).toEqual([5]); + }); + + it('does not run away when missing-albumId neighbours sit between two same-album items', () => { + // Regression: previous artist-fallback code could grab unmatched neighbours. + // With a strict same-album-id match, a null between two A's stops expansion. + const albumIds = ['A', null, 'A']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); +}); diff --git a/renderer/src/lib/queueHelpers.ts b/renderer/src/lib/queueHelpers.ts index c102bcb..880f775 100644 --- a/renderer/src/lib/queueHelpers.ts +++ b/renderer/src/lib/queueHelpers.ts @@ -6,3 +6,33 @@ export function applyReorderLocally(items: T[], fromIndices: number[], toInde const movers = [...fromIndices].sort((a, b) => a - b).map(i => items[i]); return [...remaining.slice(0, insertAt), ...movers, ...remaining.slice(insertAt)]; } + +// Returns the indices of the contiguous run of queue items that share the anchor's +// album id. Always includes the anchor. If the anchor has no resolved album id, or +// no adjacent neighbour matches, returns just the anchor (no artist fallback — +// matching by artist alone tends to grab the entire queue when every track has the +// same artist or empty/unresolved artist). +// +// `albumIds` is aligned to the queue items. Callers must supply the merged album id +// (e.g. NormalizedTrack values overlaid with whatever `useTrackDetails` resolved) +// because Sonos doesn't reliably embed album info on raw queue rows. +export function expandToAlbumBlock( + itemCount: number, + anchor: number, + albumIds: (string | null)[], +): Set { + if (anchor < 0 || anchor >= itemCount) return new Set([anchor]); + const anchorAlbum = albumIds[anchor]; + if (!anchorAlbum) return new Set([anchor]); + + const result = new Set([anchor]); + for (let i = anchor - 1; i >= 0; i--) { + if (albumIds[i] !== anchorAlbum) break; + result.add(i); + } + for (let i = anchor + 1; i < itemCount; i++) { + if (albumIds[i] !== anchorAlbum) break; + result.add(i); + } + return result; +} diff --git a/renderer/src/styles/QueueSidebar.module.css b/renderer/src/styles/QueueSidebar.module.css index 3fc0bfa..3b8a475 100644 --- a/renderer/src/styles/QueueSidebar.module.css +++ b/renderer/src/styles/QueueSidebar.module.css @@ -337,6 +337,12 @@ .selBar span { font-size: 12px; color: var(--text-2); } +.selActions { + display: flex; + align-items: center; + gap: 8px; +} + .selDelBtn { background: none; border: 1px solid rgba(255,80,80,0.4); @@ -351,6 +357,20 @@ color: #ff6464; } +.selExpandBtn { + background: none; + border: 1px solid rgba(255,255,255,0.2); + color: var(--text-2); + font-size: 11px; font-weight: 600; font-family: inherit; + padding: 4px 12px; border-radius: 12px; + cursor: pointer; transition: all 0.15s; +} +.selExpandBtn:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.35); + color: var(--text); +} + /* ── Misc ── */ .msg { padding: 28px 16px; From 076047fe281b2706c1a6daa02d3ae9d2a18cb32b Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:10:52 +0100 Subject: [PATCH 03/13] Feat: Add volume step buttons to PlayerBar --- renderer/src/components/PlayerBar.tsx | 32 ++++++++++++++++++++++++ renderer/src/styles/PlayerBar.module.css | 21 ++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/renderer/src/components/PlayerBar.tsx b/renderer/src/components/PlayerBar.tsx index 3f345df..398d51d 100644 --- a/renderer/src/components/PlayerBar.tsx +++ b/renderer/src/components/PlayerBar.tsx @@ -21,6 +21,8 @@ import { PictureInPicture2, MicVocal, Heart, + ChevronUp, + ChevronDown, } from "lucide-react"; import type { PlaybackState } from "../hooks/usePlayback"; import styles from "../styles/PlayerBar.module.css"; @@ -102,6 +104,18 @@ function VolumeButton({ volume }: { volume: number }) { }, 150); }; + const step = (delta: number) => { + setLocalVol((prev) => { + const next = Math.max(0, Math.min(100, prev + delta)); + if (next === prev) return prev; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + getActiveProvider().setVolume(next); + }, 150); + return next; + }); + }; + useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { @@ -118,6 +132,15 @@ function VolumeButton({ volume }: { volume: number }) { {open && (
{localVol} + +
)} + + + ); +} diff --git a/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx b/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx index 5028ec2..43ea589 100644 --- a/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx +++ b/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx @@ -81,6 +81,14 @@ vi.mock('../QueuedleBonusResults', () => ({ ), })); +vi.mock('../QueuedleRankChange', () => ({ + QueuedleRankChange: ({ onContinue }: { onContinue: () => void }) => ( +
+ Rank change + +
+ ), +})); vi.mock('../QueuedleSummary', () => ({ QueuedleSummary: ({ mainScore, bonusScore }: { mainScore: number; bonusScore: number }) => (
Score: {mainScore + bonusScore}
@@ -290,6 +298,7 @@ describe('QueuedlePanel', () => { await user.click(screen.getByText('Submit Bonus')); await waitFor(() => screen.getByText('Bonus results')); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); expect(screen.getByText('Score: 2')).toBeInTheDocument(); }); @@ -304,6 +313,7 @@ describe('QueuedlePanel', () => { await user.click(screen.getByText('Submit Bonus')); await waitFor(() => screen.getByText('Bonus results')); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); expect(screen.getByText('Score: 2')).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Ranked' })).toBeInTheDocument(); }); @@ -319,6 +329,7 @@ describe('QueuedlePanel', () => { await user.click(screen.getByText('Submit Bonus')); await waitFor(() => screen.getByText('Bonus results')); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); expect(screen.getByText('Score: 1')).toBeInTheDocument(); }); @@ -334,6 +345,7 @@ describe('QueuedlePanel', () => { await user.click(screen.getByText('Submit Bonus')); await waitFor(() => screen.getByText('Bonus results')); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); // main=1 (picked left, winner is left), bonus=0 (bonusSelections all null, topQueuer is 'alice') expect(screen.getByText('Score: 1')).toBeInTheDocument(); }); @@ -359,6 +371,7 @@ describe('QueuedlePanel', () => { await flushPromises(); expect(screen.getByText('Bonus results')).toBeInTheDocument(); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); expect(screen.getByText('Score: 1')).toBeInTheDocument(); }); @@ -416,7 +429,9 @@ describe('QueuedlePanel', () => { localStorage.setItem('queuedle-played:2024-01-01', JSON.stringify({ mainScore: 2, bonusScore: 1 })); render(); await waitFor(() => expect(screen.getByRole('tab', { name: 'Ranked' })).toBeInTheDocument()); - await waitFor(() => expect(mockUseGameRankings).toHaveBeenLastCalledWith('TestUser', false)); + // Rankings are now always enabled so the post-game rank-change screen has the + // user's prior totals available without a tab switch. + await waitFor(() => expect(mockUseGameRankings).toHaveBeenLastCalledWith('TestUser', true)); }); it('clicking Ranked shows average total rankings', async () => { diff --git a/renderer/src/styles/Queuedle.module.css b/renderer/src/styles/Queuedle.module.css index eb74128..ec18309 100644 --- a/renderer/src/styles/Queuedle.module.css +++ b/renderer/src/styles/Queuedle.module.css @@ -1247,3 +1247,149 @@ background: var(--bg-2); color: var(--text); } + +/* ── Rank change screen ── */ +.rankChangeWrap { + max-width: 480px; + margin: 32px auto 0; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.rankChangeTitle { + font-size: 20px; + font-weight: 700; + color: var(--text); + margin: 0 0 24px; + letter-spacing: -0.01em; +} + +.rankChangeBadgeRow { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; +} + +.rankBadgePrev, +.rankBadgeCurrent { + width: 96px; + height: 96px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.rankBadgePrev { + opacity: 0.45; + transform: scale(0.85); +} + +.rankBadgeImg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.rankBadgePlaceholder { + width: 60%; + height: 60%; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); +} + +.rankBadgeArrow { + font-size: 20px; + color: var(--text-3); +} + +@keyframes rankBadgePulse { + 0% { + transform: scale(0.85); + box-shadow: 0 0 0 0 rgba(29, 185, 84, 0.5); + } + 50% { + transform: scale(1.06); + box-shadow: 0 0 0 16px rgba(29, 185, 84, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(29, 185, 84, 0); + } +} + +.rankBadgePulse { + animation: rankBadgePulse 1.2s ease-out; +} + +.rankTierName { + font-size: 16px; + font-weight: 600; + color: var(--text); + margin-bottom: 18px; + letter-spacing: 0.02em; +} + +.rankRatingRow { + display: flex; + align-items: baseline; + justify-content: center; + gap: 14px; + margin-bottom: 12px; +} + +.rankRatingNumber { + font-size: 56px; + font-weight: 800; + color: var(--text); + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + line-height: 1; +} + +.rankDeltaChip { + font-size: 14px; + font-weight: 700; + font-variant-numeric: tabular-nums; + padding: 4px 10px; + border-radius: 999px; + animation: rankDeltaIn 0.4s ease-out both; +} + +@keyframes rankDeltaIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.rankDeltaUp { + color: #1db954; + background: rgba(29, 185, 84, 0.15); +} + +.rankDeltaDown { + color: #ff6464; + background: rgba(255, 100, 100, 0.15); +} + +.rankDeltaFlat { + color: var(--text-3); + background: rgba(255, 255, 255, 0.08); +} + +.rankProvisional { + font-size: 12px; + color: var(--text-3); + margin-top: 4px; + margin-bottom: 8px; +} From 84e4b1611647d50280bf7ee8d0994564551f377c Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:30:38 +0100 Subject: [PATCH 06/13] feat(playerbar): Introduce artistItem in useNowPlaying hook --- renderer/src/hooks/useNowPlaying.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/renderer/src/hooks/useNowPlaying.ts b/renderer/src/hooks/useNowPlaying.ts index af21ad2..a3aa05e 100644 --- a/renderer/src/hooks/useNowPlaying.ts +++ b/renderer/src/hooks/useNowPlaying.ts @@ -46,6 +46,13 @@ export function useNowPlaying(playback: PlaybackState) { resource: { type: 'ALBUM', id: { objectId: albumId, serviceId: svcId, accountId: accId } }, } as SonosItem : null; + const artistId = td?.artistId; + const artistItem: SonosItem | null = artistId ? { + title: displayArtist ?? '', + type: 'ARTIST', + resource: { type: 'ARTIST', id: { objectId: artistId, serviceId: svcId, accountId: accId } }, + } as SonosItem : null; + return { // Display values displayTrack, displayArtist, albumName, albumId, @@ -61,6 +68,7 @@ export function useNowPlaying(playback: PlaybackState) { artUrlRaw: td?.artUrl ?? artUrl ?? null, // Derived albumItem, + artistItem, prefetchAlbum, }; } From 7dea13f8edf104e53a4ba798512c32bd429189da Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:30:38 +0100 Subject: [PATCH 07/13] feat(playerbar): Separate track and artist display, make artist clickable --- renderer/src/components/PlayerBar.tsx | 28 +++++++++++++------ renderer/src/styles/PlayerBar.module.css | 35 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/renderer/src/components/PlayerBar.tsx b/renderer/src/components/PlayerBar.tsx index 398d51d..1ad0338 100644 --- a/renderer/src/components/PlayerBar.tsx +++ b/renderer/src/components/PlayerBar.tsx @@ -183,7 +183,7 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) displayTrack, displayArtist, cachedArt, dominantColor, elapsedLabel, durationLabel, progressPct, durationMs, isPlaying, isVisible, shuffle, repeat, volume, isExplicit, - albumItem, prefetchAlbum, + albumItem, artistItem, prefetchAlbum, currentObjectId, currentServiceId, currentAccountId, artUrlRaw, } = useNowPlaying(playback); @@ -250,17 +250,27 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) )}
-
+
+ {displayArtist && ( + <> + {' – '} + {artistItem ? ( + + ) : ( + {displayArtist} + )} + + )} {isExplicit && }
diff --git a/renderer/src/styles/PlayerBar.module.css b/renderer/src/styles/PlayerBar.module.css index 65342f0..ba54f00 100644 --- a/renderer/src/styles/PlayerBar.module.css +++ b/renderer/src/styles/PlayerBar.module.css @@ -72,12 +72,47 @@ flex-shrink: 0; overflow: hidden; } +.trackLine { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; +} .trackName { font-size: 13px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; + min-width: 0; +} +.trackSep { + font-size: 13px; + font-weight: 600; + color: #fff; + flex-shrink: 0; + white-space: pre; +} +.trackArtist { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 0 1 auto; +} +button.trackArtist { + background: none; + border: none; + padding: 0; + font-family: inherit; + cursor: pointer; + transition: color 0.12s; +} +button.trackArtist:hover { + text-decoration: underline; } @keyframes tt-scroll { From 58523255fb6d827398f7ecbdbb79c06983a37e25 Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:30:39 +0100 Subject: [PATCH 08/13] test(playerbar): Update track info test for separate track/artist --- renderer/src/components/__tests__/PlayerBar.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/renderer/src/components/__tests__/PlayerBar.test.tsx b/renderer/src/components/__tests__/PlayerBar.test.tsx index e8ff8a2..e7115ee 100644 --- a/renderer/src/components/__tests__/PlayerBar.test.tsx +++ b/renderer/src/components/__tests__/PlayerBar.test.tsx @@ -144,7 +144,8 @@ describe('PlayerBar — visibility', () => { describe('PlayerBar — track info', () => { it('displays track and artist name', () => { setup(); - expect(screen.getByText('Test Track - Test Artist')).toBeInTheDocument(); + expect(screen.getByText('Test Track')).toBeInTheDocument(); + expect(screen.getByText('Test Artist')).toBeInTheDocument(); }); it('shows elapsed and duration labels', () => { From e410eaa6f31f1f16ea6f74dbbc20624caa9630cd Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:37:25 +0100 Subject: [PATCH 09/13] refactor(volume): extract shared volume commitment logic --- renderer/src/components/PlayerBar.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/renderer/src/components/PlayerBar.tsx b/renderer/src/components/PlayerBar.tsx index 1ad0338..92c6b45 100644 --- a/renderer/src/components/PlayerBar.tsx +++ b/renderer/src/components/PlayerBar.tsx @@ -94,9 +94,7 @@ function VolumeButton({ volume }: { volume: number }) { if (!dragging.current) setLocalVol(volume); }, [volume]); - const handleChange = (e: React.ChangeEvent) => { - const val = Number(e.target.value); - setLocalVol(val); + const commitVolume = (val: number) => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { getActiveProvider().setVolume(val); From a52a586d3ef289da6af342c54570bad08604581e Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:37:25 +0100 Subject: [PATCH 10/13] feat(volume): add mute/unmute and hover-based popover --- renderer/src/components/PlayerBar.tsx | 66 ++++++++++++++----- .../components/__tests__/PlayerBar.test.tsx | 41 ++++++++---- renderer/src/styles/PlayerBar.module.css | 10 +++ 3 files changed, 89 insertions(+), 28 deletions(-) diff --git a/renderer/src/components/PlayerBar.tsx b/renderer/src/components/PlayerBar.tsx index 92c6b45..0d9e193 100644 --- a/renderer/src/components/PlayerBar.tsx +++ b/renderer/src/components/PlayerBar.tsx @@ -86,7 +86,8 @@ function VolumeButton({ volume }: { volume: number }) { const [open, setOpen] = useState(false); const [localVol, setLocalVol] = useState(volume); const debounceRef = useRef | null>(null); - const wrapRef = useRef(null); + const closeRef = useRef | null>(null); + const preMuteRef = useRef(volume > 0 ? volume : 50); // Sync incoming WS volume only when the user isn't actively dragging const dragging = useRef(false); @@ -102,31 +103,63 @@ function VolumeButton({ volume }: { volume: number }) { }, 150); }; + const handleChange = (e: React.ChangeEvent) => { + const val = Number(e.target.value); + setLocalVol(val); + if (val > 0) preMuteRef.current = val; + commitVolume(val); + }; + const step = (delta: number) => { setLocalVol((prev) => { const next = Math.max(0, Math.min(100, prev + delta)); if (next === prev) return prev; - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - getActiveProvider().setVolume(next); - }, 150); + if (next > 0) preMuteRef.current = next; + commitVolume(next); return next; }); }; + const toggleMute = () => { + if (localVol > 0) { + preMuteRef.current = localVol; + setLocalVol(0); + commitVolume(0); + } else { + const restored = preMuteRef.current > 0 ? preMuteRef.current : 50; + setLocalVol(restored); + commitVolume(restored); + } + }; + + const handleEnter = () => { + if (closeRef.current) { + clearTimeout(closeRef.current); + closeRef.current = null; + } + setOpen(true); + }; + + const handleLeave = () => { + if (closeRef.current) clearTimeout(closeRef.current); + closeRef.current = setTimeout(() => setOpen(false), 150); + }; + useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { - setOpen(false); - } + return () => { + if (closeRef.current) clearTimeout(closeRef.current); + if (debounceRef.current) clearTimeout(debounceRef.current); }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [open]); + }, []); + + const muted = localVol === 0; return ( -
+
{open && (
{localVol} @@ -163,8 +196,9 @@ function VolumeButton({ volume }: { volume: number }) { )} diff --git a/renderer/src/components/__tests__/PlayerBar.test.tsx b/renderer/src/components/__tests__/PlayerBar.test.tsx index e7115ee..0614eab 100644 --- a/renderer/src/components/__tests__/PlayerBar.test.tsx +++ b/renderer/src/components/__tests__/PlayerBar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, act } from '@testing-library/react'; +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PlayerBar } from '../PlayerBar'; @@ -308,25 +308,42 @@ describe('PlayerBar — seek bar', () => { // ─── volume ─────────────────────────────────────────────────────────────────── describe('PlayerBar — volume button', () => { - it('clicking Volume button shows the popover', async () => { - const { user } = setup(); - await user.click(screen.getByTitle('Volume')); + it('hovering the Volume button shows the popover', () => { + const { container } = setup(); + const wrap = container.querySelector('[class*="volWrap"]') as HTMLElement; + fireEvent.mouseEnter(wrap); expect(screen.getByRole('slider')).toBeInTheDocument(); }); - it('clicking outside volume popover closes it', async () => { - const { user } = setup(); - await user.click(screen.getByTitle('Volume')); + it('moving the mouse away closes the popover after a delay', async () => { + const { container } = setup(); + const wrap = container.querySelector('[class*="volWrap"]') as HTMLElement; + fireEvent.mouseEnter(wrap); expect(screen.getByRole('slider')).toBeInTheDocument(); - await user.click(document.body); - expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + fireEvent.mouseLeave(wrap); + await waitFor(() => expect(screen.queryByRole('slider')).not.toBeInTheDocument()); }); - it('shows current volume percentage in popover', async () => { - const { user } = setup({ volume: 72 }, { volume: 72 }); - await user.click(screen.getByTitle('Volume')); + it('shows current volume percentage in popover', () => { + const { container } = setup({ volume: 72 }, { volume: 72 }); + const wrap = container.querySelector('[class*="volWrap"]') as HTMLElement; + fireEvent.mouseEnter(wrap); expect(screen.getByText('72')).toBeInTheDocument(); }); + + it('clicking the icon mutes when volume is non-zero', async () => { + setup({ volume: 60 }, { volume: 60 }); + fireEvent.click(screen.getByTitle('Mute')); + await waitFor(() => expect(window.sonos.setGroupVolume).toHaveBeenCalledWith(0)); + }); + + it('clicking the icon while muted restores the previous volume', async () => { + setup({ volume: 60 }, { volume: 60 }); + fireEvent.click(screen.getByTitle('Mute')); + await waitFor(() => expect(window.sonos.setGroupVolume).toHaveBeenLastCalledWith(0)); + fireEvent.click(screen.getByTitle('Unmute')); + await waitFor(() => expect(window.sonos.setGroupVolume).toHaveBeenLastCalledWith(60)); + }); }); // ─── right-side controls ───────────────────────────────────────────────────── diff --git a/renderer/src/styles/PlayerBar.module.css b/renderer/src/styles/PlayerBar.module.css index ba54f00..b455640 100644 --- a/renderer/src/styles/PlayerBar.module.css +++ b/renderer/src/styles/PlayerBar.module.css @@ -289,6 +289,16 @@ button.trackArtist:hover { animation: volFadeIn 0.12s ease; } +/* Invisible bridge so hover stays continuous between icon and popover */ +.volPopover::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 100%; + height: 8px; +} + @keyframes volFadeIn { from { opacity: 0; From dd8b94c02353af4182cb118c8e1ff0efef7ab074 Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:50:02 +0100 Subject: [PATCH 11/13] feat: Add Genius API bridge for album year --- renderer/src/test/setup.ts | 1 + renderer/src/types/globals.d.ts | 1 + src/main.ts | 38 +++++++++++++++++++++++++++++++++ src/preload.ts | 3 +++ 4 files changed, 43 insertions(+) diff --git a/renderer/src/test/setup.ts b/renderer/src/test/setup.ts index 09eaf46..3dd7620 100644 --- a/renderer/src/test/setup.ts +++ b/renderer/src/test/setup.ts @@ -64,6 +64,7 @@ Object.defineProperty(window, 'sonos', { fetchRecentlyPlayed: vi.fn(() => Promise.resolve(null)), geniusDescription: vi.fn(() => Promise.resolve(null)), geniusArtist: vi.fn(() => Promise.resolve(null)), + geniusAlbumYear: vi.fn(() => Promise.resolve(null)), trackEvent: vi.fn(() => Promise.resolve()), minimizeWindow: vi.fn(() => Promise.resolve()), maximizeWindow: vi.fn(() => Promise.resolve()), diff --git a/renderer/src/types/globals.d.ts b/renderer/src/types/globals.d.ts index c3106f5..70ac4d5 100644 --- a/renderer/src/types/globals.d.ts +++ b/renderer/src/types/globals.d.ts @@ -339,6 +339,7 @@ interface SonosPreload { onAttributionEvent: (cb: (event: AttributionEvent) => void) => Unsubscribe; geniusDescription: (trackName: string, artistName: string) => Promise; geniusArtist: (artistName: string, trackHint?: string) => Promise; + geniusAlbumYear: (albumName: string, artistName: string) => Promise; /** Fire-and-forget telemetry event routed through the main process. No-op when App Insights is not configured. */ trackEvent: (name: string, properties?: Record) => Promise; minimizeWindow: () => Promise; diff --git a/src/main.ts b/src/main.ts index a3079e9..624ed05 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1914,6 +1914,44 @@ ipcMain.handle('genius:artist', async (_: IpcMainInvokeEvent, artistName: string } }); +ipcMain.handle('genius:albumYear', async (_: IpcMainInvokeEvent, albumName: string, artistName: string) => { + const key = process.env.GENIUS_ACCESS_TOKEN; + if (!key) { + log('[genius] GENIUS_ACCESS_TOKEN not set — skipping albumYear'); + return null; + } + + const debugReq = (id: string, url: string, ts: number) => + httpDebugWin?.webContents.send('http:req', { id, ts, operationId: 'genius:albumYear', method: 'GET', url, headers: { Authorization: 'Bearer ***' } }); + const debugRes = (id: string, status: number, body: string, ts: number) => + httpDebugWin?.webContents.send('http:res', { id, status, statusText: String(status), headers: {}, body, durationMs: Date.now() - ts }); + + try { + const searchUrl = `https://api.genius.com/search?q=${encodeURIComponent(`${albumName} ${artistName}`)}`; + const searchId = randomUUID(); const searchTs = Date.now(); + debugReq(searchId, searchUrl, searchTs); + const searchRes = await fetch(searchUrl, { headers: { Authorization: `Bearer ${key}` } }); + const searchBody = await searchRes.text(); + debugRes(searchId, searchRes.status, searchBody, searchTs); + if (!searchRes.ok) return null; + + type Hit = { + result: { + primary_artist: { name: string } | null; + release_date_components: { year: number | null } | null; + }; + }; + const searchData = JSON.parse(searchBody) as { response: { hits: Hit[] } }; + const hits = searchData.response?.hits ?? []; + const lower = artistName.toLowerCase(); + const hit = hits.find((h) => h.result.primary_artist?.name?.toLowerCase().includes(lower)) ?? hits[0]; + return hit?.result?.release_date_components?.year ?? null; + } catch (err) { + log(`[genius] albumYear error: ${err}`); + return null; + } +}); + // ─── Application menu ──────────────────────────────────────────────────────── function buildMenu(): void { diff --git a/src/preload.ts b/src/preload.ts index 007ea24..f4dd24e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -60,6 +60,7 @@ export interface SonosAPI { fetchRecentlyPlayed: (userId: string) => Promise; geniusDescription: (trackName: string, artistName: string) => Promise; geniusArtist: (artistName: string, trackHint?: string) => Promise; + geniusAlbumYear: (albumName: string, artistName: string) => Promise; trackEvent: (name: string, properties?: Record) => Promise; fetchPlaylists: (filter: { owner?: string; member?: string }) => Promise; fetchPlaylist: (id: string) => Promise; @@ -194,6 +195,8 @@ contextBridge.exposeInMainWorld('sonos', { ipcRenderer.invoke('genius:description', trackName, artistName), geniusArtist: (artistName: string, trackHint?: string) => ipcRenderer.invoke('genius:artist', artistName, trackHint), + geniusAlbumYear: (albumName: string, artistName: string) => + ipcRenderer.invoke('genius:albumYear', albumName, artistName), trackEvent: (name: string, properties?: Record) => ipcRenderer.invoke('telemetry:event', name, properties), minimizeWindow: () => ipcRenderer.invoke('win:minimize'), From dbc17a6618130c2ce2f20df924540ab950395378 Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:50:03 +0100 Subject: [PATCH 12/13] feat(renderer): Implement useGeniusAlbumYear hook --- renderer/src/hooks/useGeniusAlbumYear.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 renderer/src/hooks/useGeniusAlbumYear.ts diff --git a/renderer/src/hooks/useGeniusAlbumYear.ts b/renderer/src/hooks/useGeniusAlbumYear.ts new file mode 100644 index 0000000..e0d7cbd --- /dev/null +++ b/renderer/src/hooks/useGeniusAlbumYear.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; + +export function geniusAlbumYearQueryOptions( + albumName: string | null | undefined, + artistName: string | null | undefined, +) { + return { + queryKey: ['genius-album-year', albumName, artistName] as const, + queryFn: (): Promise => + window.sonos.geniusAlbumYear(albumName!, artistName!), + staleTime: Infinity, + retry: false, + enabled: !!albumName && !!artistName, + }; +} + +export function useGeniusAlbumYear( + albumName: string | null | undefined, + artistName: string | null | undefined, +) { + const { data } = useQuery(geniusAlbumYearQueryOptions(albumName, artistName)); + return data ?? null; +} From 4f1be22d49b8de89796c94453e07467ff4d1d731 Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Mon, 18 May 2026 21:50:03 +0100 Subject: [PATCH 13/13] feat(album-panel): Display album year from Genius --- renderer/src/components/album/AlbumPanel.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/renderer/src/components/album/AlbumPanel.tsx b/renderer/src/components/album/AlbumPanel.tsx index f7725cc..692236f 100644 --- a/renderer/src/components/album/AlbumPanel.tsx +++ b/renderer/src/components/album/AlbumPanel.tsx @@ -5,6 +5,7 @@ import { useImage } from '../../hooks/useImage'; import { useAlbumBrowse } from '../../hooks/useAlbumBrowse'; import { usePlaylistBrowse } from '../../hooks/usePlaylistBrowse'; import { useDominantColor } from '../../hooks/useDominantColor'; +import { useGeniusAlbumYear } from '../../hooks/useGeniusAlbumYear'; import { artistQueryOptions } from '../../hooks/useArtistBrowse'; import { resolveAlbumParams, isPlaylist, isProgram, getItemArt } from '../../lib/itemHelpers'; import { createDragGhost } from '../../lib/dragHelpers'; @@ -53,6 +54,10 @@ export function AlbumPanel({ onAddToQueue }: Props) { const artUrl = data?.artUrl ?? (item ? getItemArt(item) : null); const cachedArt = useImage(artUrl); const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); + const year = useGeniusAlbumYear( + isPlaylistOrProgram ? null : title, + isPlaylistOrProgram ? null : artist, + ); useEffect(() => { setSelected(new Set()); @@ -120,7 +125,7 @@ export function AlbumPanel({ onAddToQueue }: Props) { )} {data && (
- {[data.totalTracks + ' songs', totalMins > 0 ? totalMins + ' min' : null].filter(Boolean).join(' \u2022 ')} + {[data.totalTracks + ' songs', totalMins > 0 ? totalMins + ' min' : null, year].filter(Boolean).join(' \u2022 ')}
)}