From 85720f517d5fdc6cc203ab72ea6eae2d630151fd Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 8 May 2026 10:33:49 +0100 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20recently=20played=20from=20Cosmos?= =?UTF-8?q?=20=E2=80=94=20artists,=20albums,=20tracks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the broken api.content.history (Sonos, album-only) with a new Azure Function that queries the Cosmos events container for the current user's queue history from the past 7 days. - New Azure Function /api/recently-played — deduplicates in-memory, returns top 20 tracks / 10 artists / 10 albums sorted by most-recent - IPC handler history:recent + preload bridge fetchRecentlyPlayed - useRecentlyPlayed hook — fetches by displayName, converts to SonosItem so CardRow/useOpenItem can render and navigate them - HomePanel: three conditional rows (Artists, Albums, Tracks) replacing the single dead Recently Played row Needs: cd server && npm run deploy Co-Authored-By: Claude Sonnet 4.6 --- renderer/src/App.tsx | 25 ++- renderer/src/components/HomePanel.tsx | 38 +++- .../components/__tests__/HomePanel.test.tsx | 35 ++-- renderer/src/hooks/useRecentlyPlayed.ts | 61 +++++++ renderer/src/test/setup.ts | 1 + renderer/src/types/globals.d.ts | 40 +++++ server/src/functions/recently-played.ts | 162 ++++++++++++++++++ src/main.ts | 10 ++ src/preload.ts | 3 + 9 files changed, 339 insertions(+), 36 deletions(-) create mode 100644 renderer/src/hooks/useRecentlyPlayed.ts create mode 100644 server/src/functions/recently-played.ts diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index 9cc2c28..e8a0a73 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -10,6 +10,7 @@ import { trackQueryOptions } from './hooks/useTrackDetails'; import { albumQueryOptions, type AlbumTrack } from './hooks/useAlbumBrowse'; import { playlistQueryOptions } from './hooks/usePlaylistBrowse'; import { api } from './lib/sonosApi'; +import { useRecentlyPlayed } from './hooks/useRecentlyPlayed'; import { normalizeForQueue, isTrack, @@ -410,17 +411,9 @@ useEffect(() => { staleTime: 5 * 60 * 1000, }); - const { data: history = [], isLoading: histLoading } = useQuery({ - queryKey: ['history'], - queryFn: async () => { - const r = await api.content.history({ count: 20 }); - return r.error ? [] : extractItems(r.data); - }, - enabled: homeEnabled, - staleTime: 60 * 1000, - }); + const { artistItems: recentArtists, albumItems: recentAlbums, trackItems: recentTracks, isLoading: recentLoading } = useRecentlyPlayed(); - const splashReady = isAuthed && groups.length > 0 && !ytmLoading && !histLoading; + const splashReady = isAuthed && groups.length > 0 && !ytmLoading; return (
{ onAddToQueue={handleAddToQueue} ytm={ytm} ytmLoading={ytmLoading} - history={history} - histLoading={histLoading} + recentArtists={recentArtists} + recentAlbums={recentAlbums} + recentTracks={recentTracks} + recentLoading={recentLoading} /> } /> @@ -465,8 +460,10 @@ useEffect(() => { onAddToQueue={handleAddToQueue} ytm={ytm} ytmLoading={ytmLoading} - history={history} - histLoading={histLoading} + recentArtists={recentArtists} + recentAlbums={recentAlbums} + recentTracks={recentTracks} + recentLoading={recentLoading} /> } /> diff --git a/renderer/src/components/HomePanel.tsx b/renderer/src/components/HomePanel.tsx index e515249..ae3faa8 100644 --- a/renderer/src/components/HomePanel.tsx +++ b/renderer/src/components/HomePanel.tsx @@ -24,8 +24,10 @@ interface Props { onAddToQueue: (item: SonosItem) => void; ytm: YtmSections | undefined; ytmLoading: boolean; - history: SonosItem[]; - histLoading: boolean; + recentArtists: SonosItem[]; + recentAlbums: SonosItem[]; + recentTracks: SonosItem[]; + recentLoading: boolean; } export interface YtmSections { @@ -79,7 +81,7 @@ export async function fetchYtmSections(): Promise { }; } -export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, history, histLoading }: Props) { +export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, recentArtists, recentAlbums, recentTracks, recentLoading }: Props) { const queryClient = useQueryClient(); const location = useLocation(); const [searchParams] = useSearchParams(); @@ -141,12 +143,30 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, history, hi -
-
-

Recently Played

-
- -
+ {(recentLoading || recentArtists.length > 0) && ( +
+
+

Recently Played Artists

+
+ +
+ )} + {(recentLoading || recentAlbums.length > 0) && ( +
+
+

Recently Played Albums

+
+ +
+ )} + {(recentLoading || recentTracks.length > 0) && ( +
+
+

Recently Played Tracks

+
+ +
+ )}
diff --git a/renderer/src/components/__tests__/HomePanel.test.tsx b/renderer/src/components/__tests__/HomePanel.test.tsx index 659a76b..cc2c2e9 100644 --- a/renderer/src/components/__tests__/HomePanel.test.tsx +++ b/renderer/src/components/__tests__/HomePanel.test.tsx @@ -59,8 +59,10 @@ const defaultProps = { onAddToQueue: vi.fn(), ytm: { forYou: [], newReleases: [], charts: [] }, ytmLoading: false, - history: [], - histLoading: false, + recentArtists: [], + recentAlbums: [], + recentTracks: [], + recentLoading: false, }; beforeEach(() => { @@ -106,8 +108,7 @@ describe('fetchYtmSections', () => { const track: SonosItem = { name: 'Track A', type: 'TRACK' } as SonosItem; mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([homeItem]) }) - .mockResolvedValueOnce({ error: null, data: { items: [track] } }); // home browse - // nr and charts return empty (default mock) + .mockResolvedValueOnce({ error: null, data: { items: [track] } }); const result = await fetchYtmSections(); expect(result.forYou).toHaveLength(1); @@ -120,7 +121,7 @@ describe('fetchYtmSections', () => { const track: SonosItem = { name: 'Home Track', type: 'TRACK' } as SonosItem; mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([supermixItem, homeItem]) }) - .mockResolvedValueOnce({ error: null, data: { items: [track] } }); // home browse + .mockResolvedValueOnce({ error: null, data: { items: [track] } }); const result = await fetchYtmSections(); expect(result.forYou[0].title).toBe('My Supermix'); @@ -139,7 +140,7 @@ describe('fetchYtmSections', () => { const homeItem = makeRootItem('Home', 'home-id'); mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([homeItem]) }) - .mockResolvedValueOnce({ error: 'fail', data: null }); // home browse fails + .mockResolvedValueOnce({ error: 'fail', data: null }); const result = await fetchYtmSections(); expect(result.forYou).toEqual([]); @@ -152,7 +153,6 @@ describe('HomePanel', () => { it('shows sections when authed on home view', () => { render(, { wrapper: makeWrapper() }); expect(screen.getByText('For You')).toBeInTheDocument(); - expect(screen.getByText('Recently Played')).toBeInTheDocument(); expect(screen.getByText('New Releases')).toBeInTheDocument(); expect(screen.getByText('Charts')).toBeInTheDocument(); }); @@ -174,15 +174,24 @@ describe('HomePanel', () => { expect(screen.getAllByText('Loading cards').length).toBeGreaterThan(0); }); - it('shows loading card row when histLoading is true', () => { - render(, { wrapper: makeWrapper() }); + it('shows loading card rows when recentLoading is true', () => { + const artist = { type: 'ARTIST', title: 'The Beatles' } as SonosItem; + render(, { wrapper: makeWrapper() }); expect(screen.getAllByText('Loading cards').length).toBeGreaterThan(0); }); - it('passes history items to recently played row', () => { - const history = [{ name: 'Track 1', type: 'TRACK' } as SonosItem, { name: 'Track 2', type: 'TRACK' } as SonosItem]; - render(, { wrapper: makeWrapper() }); - expect(screen.getByText('Card row (2)')).toBeInTheDocument(); + it('renders recently played artist row when data is present', () => { + const artist = { type: 'ARTIST', title: 'The Beatles' } as SonosItem; + render(, { wrapper: makeWrapper() }); + expect(screen.getByText('Recently Played Artists')).toBeInTheDocument(); + expect(screen.getByText('Card row (1)')).toBeInTheDocument(); + }); + + it('hides recently played sections when all are empty and not loading', () => { + render(, { wrapper: makeWrapper() }); + expect(screen.queryByText('Recently Played Artists')).not.toBeInTheDocument(); + expect(screen.queryByText('Recently Played Albums')).not.toBeInTheDocument(); + expect(screen.queryByText('Recently Played Tracks')).not.toBeInTheDocument(); }); it('shows SearchResults on /search path after query resolves', async () => { diff --git a/renderer/src/hooks/useRecentlyPlayed.ts b/renderer/src/hooks/useRecentlyPlayed.ts new file mode 100644 index 0000000..b6124b9 --- /dev/null +++ b/renderer/src/hooks/useRecentlyPlayed.ts @@ -0,0 +1,61 @@ +import { useQuery } from '@tanstack/react-query'; +import type { SonosItem } from '../types/sonos'; + +function toArtistItem(a: RecentArtist): SonosItem { + return { + type: 'ARTIST', + title: a.artist, + name: a.artist, + imageUrl: a.imageUrl, + resource: { + type: 'ARTIST', + id: { objectId: a.artistId, serviceId: a.serviceId, accountId: a.accountId }, + }, + }; +} + +function toAlbumItem(a: RecentAlbum): SonosItem { + return { + type: 'ITEM_ALBUM', + title: a.album, + name: a.artist, + imageUrl: a.imageUrl, + id: { objectId: a.albumId, serviceId: a.serviceId, accountId: a.accountId }, + }; +} + +function toTrackItem(t: RecentTrack): SonosItem { + return { + type: 'ITEM_TRACK', + title: t.trackName, + name: t.trackName, + imageUrl: t.imageUrl, + // Use albumId so useOpenItem's else branch navigates to the parent album + id: { objectId: t.albumId ?? '', serviceId: t.serviceId, accountId: t.accountId }, + }; +} + +export function useRecentlyPlayed() { + const { data: userId } = useQuery({ + queryKey: ['displayName'], + queryFn: () => window.sonos.getDisplayName(), + staleTime: Infinity, + }); + + const { data, isLoading } = useQuery({ + queryKey: ['recentlyPlayed', userId], + queryFn: async () => { + if (!userId) return null; + return window.sonos.fetchRecentlyPlayed(userId); + }, + enabled: !!userId, + staleTime: 5 * 60 * 1000, + }); + + return { + artistItems: (data?.artists ?? []).map(toArtistItem), + albumItems: (data?.albums ?? []).map(toAlbumItem), + trackItems: (data?.tracks ?? []).filter(t => t.albumId).map(toTrackItem), + isLoading: !!userId && isLoading, + }; +} diff --git a/renderer/src/test/setup.ts b/renderer/src/test/setup.ts index 4bd396b..863568b 100644 --- a/renderer/src/test/setup.ts +++ b/renderer/src/test/setup.ts @@ -61,6 +61,7 @@ Object.defineProperty(window, 'sonos', { getVersion: vi.fn(pending), isNewVersion: vi.fn(() => Promise.resolve(false)), openExternal: vi.fn(() => Promise.resolve()), + fetchRecentlyPlayed: vi.fn(() => Promise.resolve(null)), geniusDescription: vi.fn(() => Promise.resolve(null)), geniusArtist: vi.fn(() => Promise.resolve(null)), trackEvent: vi.fn(() => Promise.resolve()), diff --git a/renderer/src/types/globals.d.ts b/renderer/src/types/globals.d.ts index 9395a5a..f95bd19 100644 --- a/renderer/src/types/globals.d.ts +++ b/renderer/src/types/globals.d.ts @@ -189,6 +189,45 @@ interface GameStatsResult { error?: string; } +interface RecentTrack { + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + uri?: string; + lastPlayed: number; +} + +interface RecentArtist { + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + imageUrl?: string; + lastPlayed: number; +} + +interface RecentAlbum { + album: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + albumId?: string; + imageUrl?: string; + lastPlayed: number; +} + +interface RecentlyPlayedData { + tracks: RecentTrack[]; + artists: RecentArtist[]; + albums: RecentAlbum[]; +} + interface SonosPreload { getVersion: () => Promise; isNewVersion: () => Promise; @@ -239,6 +278,7 @@ interface SonosPreload { albumId?: string; imageUrl?: string; }) => Promise; + fetchRecentlyPlayed: (userId: string) => Promise; fetchStats: (period: string, userId?: string) => Promise; fetchDailyGame: (date?: string) => Promise; submitGameScore: (input: { diff --git a/server/src/functions/recently-played.ts b/server/src/functions/recently-played.ts new file mode 100644 index 0000000..9cd48dd --- /dev/null +++ b/server/src/functions/recently-played.ts @@ -0,0 +1,162 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +interface RawEvent { + eventType?: 'track' | 'album' | null; + trackName: string; + artist: string; + serviceId?: string | null; + accountId?: string | null; + artistId?: string | null; + album?: string | null; + albumId?: string | null; + imageUrl?: string | null; + uri?: string | null; + timestamp: number; +} + +interface RecentTrack { + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + uri?: string; + lastPlayed: number; +} + +interface RecentArtist { + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + imageUrl?: string; + lastPlayed: number; +} + +interface RecentAlbum { + album: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + albumId?: string; + imageUrl?: string; + lastPlayed: number; +} + +export async function recentlyPlayedHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + const ctrName = process.env['COSMOS_CONTAINER'] ?? 'events'; + + if (!connStr) return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + + const userId = request.query.get('userId'); + if (!userId) return { status: 400, jsonBody: { error: 'userId required' } }; + + const days = parseInt(request.query.get('days') ?? '7', 10); + const startMs = Date.now() - days * 24 * 60 * 60 * 1000; + + try { + const client = new CosmosClient(connStr); + const container = client.database(dbName).container(ctrName); + + const { resources } = await container.items.query({ + query: `SELECT c.eventType, c.trackName, c.artist, c.serviceId, c.accountId, + c.artistId, c.album, c.albumId, c.imageUrl, c.uri, c.timestamp + FROM c + WHERE c.userId = @userId AND c.timestamp >= @start`, + parameters: [ + { name: '@userId', value: userId }, + { name: '@start', value: startMs }, + ], + }).fetchAll(); + + // Sort most-recent first, then deduplicate — keeping the first (most recent) occurrence of each key. + resources.sort((a, b) => b.timestamp - a.timestamp); + + const trackMap = new Map(); + const artistMap = new Map(); + const albumMap = new Map(); + + for (const e of resources) { + const isTrack = e.eventType === 'track' || e.eventType == null; + const isAlbumEvt = e.eventType === 'album' || e.eventType == null; + + if (isTrack && e.trackName && e.artist) { + const key = `${e.trackName}||${e.artist}`; + if (!trackMap.has(key)) { + trackMap.set(key, { + trackName: e.trackName, + artist: e.artist, + serviceId: e.serviceId ?? undefined, + accountId: e.accountId ?? undefined, + artistId: e.artistId ?? undefined, + album: e.album ?? undefined, + albumId: e.albumId ?? undefined, + imageUrl: e.imageUrl ?? undefined, + uri: e.uri ?? undefined, + lastPlayed: e.timestamp, + }); + } + + if (e.artist) { + const aKey = e.artist; + if (!artistMap.has(aKey)) { + artistMap.set(aKey, { + artist: e.artist, + serviceId: e.serviceId ?? undefined, + accountId: e.accountId ?? undefined, + artistId: e.artistId ?? undefined, + imageUrl: e.imageUrl ?? undefined, + lastPlayed: e.timestamp, + }); + } + } + } + + if (isAlbumEvt && e.album) { + const aKey = e.albumId ?? e.album; + if (!albumMap.has(aKey)) { + albumMap.set(aKey, { + album: e.album, + artist: e.artist, + serviceId: e.serviceId ?? undefined, + accountId: e.accountId ?? undefined, + artistId: e.artistId ?? undefined, + albumId: e.albumId ?? undefined, + imageUrl: e.imageUrl ?? undefined, + lastPlayed: e.timestamp, + }); + } + } + } + + const tracks = [...trackMap.values()].slice(0, 20); + const artists = [...artistMap.values()].slice(0, 10); + const albums = [...albumMap.values()].slice(0, 10); + + context.log(`[recently-played] userId=${userId} tracks=${tracks.length} artists=${artists.length} albums=${albums.length}`); + + return { + jsonBody: { tracks, artists, albums }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[recently-played] query failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('recently-played', { + methods: ['GET'], + authLevel: 'anonymous', + handler: recentlyPlayedHandler, +}); diff --git a/src/main.ts b/src/main.ts index 32794ce..be9829b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1272,6 +1272,16 @@ ipcMain.handle('stats:fetch', async (_: IpcMainInvokeEvent, period: string, user } }); +ipcMain.handle('history:recent', async (_: IpcMainInvokeEvent, userId: string) => { + try { + const url = `${PUBSUB_FUNCTION_URL}/api/recently-played?userId=${encodeURIComponent(userId)}`; + const res = await fetch(url); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + ipcMain.handle('game:fetch', async (_: IpcMainInvokeEvent, date?: string) => { try { diff --git a/src/preload.ts b/src/preload.ts index f102e61..6ff6955 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -57,6 +57,7 @@ export interface SonosAPI { fetchGameDates: (userName: string) => Promise; fetchMyScore: (gameId: string, userName: string) => Promise; fetchGameStats: (date?: string) => Promise; + fetchRecentlyPlayed: (userId: string) => Promise; geniusDescription: (trackName: string, artistName: string) => Promise; geniusArtist: (artistName: string, trackHint?: string) => Promise; trackEvent: (name: string, properties?: Record) => Promise; @@ -173,6 +174,8 @@ contextBridge.exposeInMainWorld('sonos', { ipcRenderer.on('attribution:event', listener); return () => ipcRenderer.removeListener('attribution:event', listener); }, + fetchRecentlyPlayed: (userId: string) => + ipcRenderer.invoke('history:recent', userId), geniusDescription: (trackName: string, artistName: string) => ipcRenderer.invoke('genius:description', trackName, artistName), geniusArtist: (artistName: string, trackHint?: string) => From 1b7a97e51d462ebb97150c6883edc1c2452347b1 Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 8 May 2026 11:07:37 +0100 Subject: [PATCH 02/10] fix: album and track items show correct name in recently played MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getName() prefers item.name over item.title — toAlbumItem was setting name: a.artist which caused the album row to display artist names. Also wire artist field on both album and track items for subtitle display. Co-Authored-By: Claude Sonnet 4.6 --- renderer/src/hooks/useRecentlyPlayed.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/renderer/src/hooks/useRecentlyPlayed.ts b/renderer/src/hooks/useRecentlyPlayed.ts index b6124b9..0e5679c 100644 --- a/renderer/src/hooks/useRecentlyPlayed.ts +++ b/renderer/src/hooks/useRecentlyPlayed.ts @@ -18,7 +18,8 @@ function toAlbumItem(a: RecentAlbum): SonosItem { return { type: 'ITEM_ALBUM', title: a.album, - name: a.artist, + name: a.album, + artist: a.artist, imageUrl: a.imageUrl, id: { objectId: a.albumId, serviceId: a.serviceId, accountId: a.accountId }, }; @@ -29,6 +30,7 @@ function toTrackItem(t: RecentTrack): SonosItem { type: 'ITEM_TRACK', title: t.trackName, name: t.trackName, + artist: t.artist, imageUrl: t.imageUrl, // Use albumId so useOpenItem's else branch navigates to the parent album id: { objectId: t.albumId ?? '', serviceId: t.serviceId, accountId: t.accountId }, From e6ba1a4b53ba22f183a4693217c15ee21a2fe4dc Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 8 May 2026 15:13:47 +0100 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20recently=20played=20=E2=80=94=20c?= =?UTF-8?q?ustom=20album/artist=20grid=20with=20drag-to-queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace CardRow with a bespoke side-by-side layout: album list (left) and circular artist grid (right), max 9 artists capped at 3×N columns - Artist images fetched in parallel via useQueries; circular cards with fade-in glow on hover; artist names shown below circles - Album rows: draggable (application/sonos-item-list), add-to-queue button visible on hover, grab cursor - When no albums present, artist grid spans full width with up to 5 cols - User picker inline in section title; locks with tooltip when Quedle not yet completed; "Nothing queued in the last 7 days" empty state - Loading skeleton mirrors the real layout with staggered pulse animation - Server: returns availableUsers (last 30 days) alongside recent data - Artists capped at 9 (3×3 max); CardRow artist cards now circular with no add-to-queue button Co-Authored-By: Claude Sonnet 4.6 --- renderer/src/App.tsx | 11 - renderer/src/components/CardRow.tsx | 7 +- renderer/src/components/HomePanel.tsx | 233 +++++++++++-- .../components/__tests__/HomePanel.test.tsx | 53 ++- .../components/common/MediaCard.module.css | 16 + renderer/src/components/common/MediaCard.tsx | 5 +- renderer/src/hooks/useRecentlyPlayed.ts | 68 ++-- renderer/src/styles/CardRow.module.css | 3 +- renderer/src/styles/HomePanel.module.css | 325 ++++++++++++++++++ renderer/src/types/globals.d.ts | 1 + server/src/functions/recently-played.ts | 34 +- 11 files changed, 665 insertions(+), 91 deletions(-) diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index e8a0a73..2068388 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -10,7 +10,6 @@ import { trackQueryOptions } from './hooks/useTrackDetails'; import { albumQueryOptions, type AlbumTrack } from './hooks/useAlbumBrowse'; import { playlistQueryOptions } from './hooks/usePlaylistBrowse'; import { api } from './lib/sonosApi'; -import { useRecentlyPlayed } from './hooks/useRecentlyPlayed'; import { normalizeForQueue, isTrack, @@ -411,8 +410,6 @@ useEffect(() => { staleTime: 5 * 60 * 1000, }); - const { artistItems: recentArtists, albumItems: recentAlbums, trackItems: recentTracks, isLoading: recentLoading } = useRecentlyPlayed(); - const splashReady = isAuthed && groups.length > 0 && !ytmLoading; return ( @@ -445,10 +442,6 @@ useEffect(() => { onAddToQueue={handleAddToQueue} ytm={ytm} ytmLoading={ytmLoading} - recentArtists={recentArtists} - recentAlbums={recentAlbums} - recentTracks={recentTracks} - recentLoading={recentLoading} /> } /> @@ -460,10 +453,6 @@ useEffect(() => { onAddToQueue={handleAddToQueue} ytm={ytm} ytmLoading={ytmLoading} - recentArtists={recentArtists} - recentAlbums={recentAlbums} - recentTracks={recentTracks} - recentLoading={recentLoading} /> } /> diff --git a/renderer/src/components/CardRow.tsx b/renderer/src/components/CardRow.tsx index 4c396f6..7472078 100644 --- a/renderer/src/components/CardRow.tsx +++ b/renderer/src/components/CardRow.tsx @@ -1,4 +1,4 @@ -import { getName, browseSub, getItemArt, isAlbum, isPlaylist, isContainer, isProgram } from '../lib/itemHelpers'; +import { getName, browseSub, getItemArt, isAlbum, isArtist, isPlaylist, isContainer, isProgram } from '../lib/itemHelpers'; import { MediaCard } from './common/MediaCard'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/CardRow.module.css'; @@ -32,8 +32,9 @@ export function CardRow({ name={getName(item)} sub={browseSub(item)} artUrl={getItemArt(item)} - onAdd={isContainer(item) ? undefined : () => onAdd(item)} - onOpen={(isAlbum(item) || isPlaylist(item) || isContainer(item) || isProgram(item)) ? () => onOpen(item) : undefined} + circular={isArtist(item)} + onAdd={(isContainer(item) || isArtist(item)) ? undefined : () => onAdd(item)} + onOpen={(isAlbum(item) || isPlaylist(item) || isContainer(item) || isProgram(item) || isArtist(item)) ? () => onOpen(item) : undefined} /> ))}
diff --git a/renderer/src/components/HomePanel.tsx b/renderer/src/components/HomePanel.tsx index ae3faa8..4603676 100644 --- a/renderer/src/components/HomePanel.tsx +++ b/renderer/src/components/HomePanel.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useLocation, useSearchParams } from 'react-router-dom'; import { api } from '../lib/sonosApi'; @@ -9,8 +9,13 @@ import { resolveArtistParams, isAlbum, isArtist, + getName, + browseSub, } from '../lib/itemHelpers'; import { useOpenItem } from '../hooks/useOpenItem'; +import { useRecentlyPlayed } from '../hooks/useRecentlyPlayed'; +import { useDailyGame, useMyScore } from '../hooks/useDailyGame'; +import { useImage } from '../hooks/useImage'; import type { ServiceSearch } from '../types/ServiceSearch'; import { albumQueryOptions } from '../hooks/useAlbumBrowse'; import { artistQueryOptions } from '../hooks/useArtistBrowse'; @@ -19,15 +24,63 @@ import { SearchResults } from './search/SearchResults'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/HomePanel.module.css'; +function AlbumListItem({ item, onOpen, onAdd }: { item: SonosItem; onOpen: () => void; onAdd: () => void }) { + const art = useImage(item.imageUrl); + + function handleDragStart(e: React.DragEvent) { + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify([item])); + const ghost = document.createElement('div'); + Object.assign(ghost.style, { + position: 'fixed', top: '-100px', left: '0', + background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', + color: '#fff', padding: '5px 12px', borderRadius: '6px', + fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', + }); + ghost.textContent = getName(item); + document.body.appendChild(ghost); + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); + setTimeout(() => ghost.remove(), 0); + } + + return ( +
+
+ {art ? :
} +
+
+
{getName(item)}
+
{browseSub(item)}
+
+ +
+ ); +} + +function ArtistGridCell({ item, onOpen }: { item: SonosItem; onOpen: () => void }) { + const art = useImage(item.imageUrl); + return ( +
+
+ {art + ? + :
+ } +
+
{getName(item)}
+
+ ); +} + interface Props { isAuthed: boolean; onAddToQueue: (item: SonosItem) => void; ytm: YtmSections | undefined; ytmLoading: boolean; - recentArtists: SonosItem[]; - recentAlbums: SonosItem[]; - recentTracks: SonosItem[]; - recentLoading: boolean; } export interface YtmSections { @@ -81,7 +134,7 @@ export async function fetchYtmSections(): Promise { }; } -export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, recentArtists, recentAlbums, recentTracks, recentLoading }: Props) { +export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading }: Props) { const queryClient = useQueryClient(); const location = useLocation(); const [searchParams] = useSearchParams(); @@ -90,6 +143,31 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, recentArtis const view = location.pathname === '/search' ? 'search' : 'home'; const activeSearch = searchParams.get('q') ?? ''; + const [selectedUser, setSelectedUser] = useState(undefined); + const [pickerOpen, setPickerOpen] = useState(false); + const pickerRef = useRef(null); + const { artistItems, albumItems, availableUsers, currentUserId, isLoading: recentLoading } = useRecentlyPlayed(selectedUser); + + // Default to current user once we know who they are + useEffect(() => { + if (currentUserId && !selectedUser) setSelectedUser(currentUserId); + }, [currentUserId, selectedUser]); + + const { data: todayGame } = useDailyGame(); + const todayGameId = todayGame && 'id' in todayGame ? todayGame.id : null; + const { data: myScore } = useMyScore(todayGameId, currentUserId); + const hasCompletedToday = !!myScore?.score; + + // Close picker on outside click + useEffect(() => { + if (!pickerOpen) return; + const handler = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) setPickerOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [pickerOpen]); + const { data: searchResults = [], isFetching: searchLoading } = useQuery({ queryKey: ['search', activeSearch], queryFn: async () => { @@ -130,6 +208,11 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, recentArtis ); } + const hasRecent = !!selectedUser || recentLoading || artistItems.length > 0 || albumItems.length > 0; + const recentEmpty = !!selectedUser && !recentLoading && artistItems.length === 0 && albumItems.length === 0; + const users = availableUsers.length > 0 ? availableUsers : (currentUserId ? [currentUserId] : []); + const isPickerLocked = users.length > 1 && !hasCompletedToday; + return (
{!isAuthed ? ( @@ -143,29 +226,123 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, recentArtis
- {(recentLoading || recentArtists.length > 0) && ( -
-
-

Recently Played Artists

+ {hasRecent && ( + <> +
+

+ Recently Played — Last 7 Days + {selectedUser && ( + + )} + {selectedUser && ( + + + {pickerOpen && !isPickerLocked && users.length > 1 && ( +
    + {users.map((u) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +

- -
- )} - {(recentLoading || recentAlbums.length > 0) && ( -
-
-

Recently Played Albums

-
- -
- )} - {(recentLoading || recentTracks.length > 0) && ( -
-
-

Recently Played Tracks

-
- -
+ + {recentEmpty && ( +
Nothing queued in the last 7 days
+ )} + + {recentLoading && ( +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+
+ {Array.from({ length: 9 }).map((_, i) => ( +
+
+
+ ))} +
+
+
+ )} + + {!recentEmpty && !recentLoading && ( +
+ {albumItems.length > 0 && ( +
+

Albums

+ {albumItems.map((item, i) => ( + openItem(item)} onAdd={() => onAddToQueue(item)} /> + ))} +
+ )} + {artistItems.length > 0 && (() => { + const noAlbums = albumItems.length === 0; + const numCols = Math.min(noAlbums ? 5 : 3, artistItems.length); + const numRows = Math.ceil(artistItems.length / numCols); + const gridHeight = noAlbums + ? numRows === 1 ? 200 : numRows * 80 + : albumItems.length * 62 - 2; + return ( +
+

Artists

+
+ {artistItems.map((item, i) => ( + openItem(item)} /> + ))} +
+
+ ); + })()} +
+ )} + )}
diff --git a/renderer/src/components/__tests__/HomePanel.test.tsx b/renderer/src/components/__tests__/HomePanel.test.tsx index cc2c2e9..36d7494 100644 --- a/renderer/src/components/__tests__/HomePanel.test.tsx +++ b/renderer/src/components/__tests__/HomePanel.test.tsx @@ -37,6 +37,11 @@ vi.mock('../search/SearchResults', () => ({ SearchResults: ({ results }: { results: SonosItem[] }) =>
Search results ({results.length})
, })); +const mockUseRecentlyPlayed = vi.fn(); +vi.mock('../../hooks/useRecentlyPlayed', () => ({ + useRecentlyPlayed: (...args: unknown[]) => mockUseRecentlyPlayed(...args), +})); + function makeWrapper() { return ({ children }: { children: React.ReactNode }) => createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); @@ -54,15 +59,19 @@ function makeRootItem(title: string, objectId: string): SonosItem { } as unknown as SonosItem; } +const defaultRecent = { + artistItems: [], + albumItems: [], + availableUsers: [], + currentUserId: 'testuser', + isLoading: false, +}; + const defaultProps = { isAuthed: true, onAddToQueue: vi.fn(), ytm: { forYou: [], newReleases: [], charts: [] }, ytmLoading: false, - recentArtists: [], - recentAlbums: [], - recentTracks: [], - recentLoading: false, }; beforeEach(() => { @@ -71,6 +80,7 @@ beforeEach(() => { mockUseSearchParams.mockReturnValue([new URLSearchParams()]); mockServiceQuery.mockResolvedValue({ error: null, data: { items: [] } }); mockBrowseContainer.mockResolvedValue({ error: null, data: { items: [] } }); + mockUseRecentlyPlayed.mockReturnValue(defaultRecent); }); // ── fetchYtmSections ──────────────────────────────────────────────────────── @@ -174,24 +184,39 @@ describe('HomePanel', () => { expect(screen.getAllByText('Loading cards').length).toBeGreaterThan(0); }); - it('shows loading card rows when recentLoading is true', () => { + it('shows loading skeleton when recentLoading is true', () => { const artist = { type: 'ARTIST', title: 'The Beatles' } as SonosItem; - render(, { wrapper: makeWrapper() }); - expect(screen.getAllByText('Loading cards').length).toBeGreaterThan(0); + mockUseRecentlyPlayed.mockReturnValue({ ...defaultRecent, artistItems: [artist], isLoading: true }); + const { container } = render(, { wrapper: makeWrapper() }); + expect(container.querySelector('.recentGrid')).toBeInTheDocument(); }); - it('renders recently played artist row when data is present', () => { + it('renders recently played section when data is present', () => { const artist = { type: 'ARTIST', title: 'The Beatles' } as SonosItem; - render(, { wrapper: makeWrapper() }); - expect(screen.getByText('Recently Played Artists')).toBeInTheDocument(); - expect(screen.getByText('Card row (1)')).toBeInTheDocument(); + mockUseRecentlyPlayed.mockReturnValue({ ...defaultRecent, artistItems: [artist] }); + render(, { wrapper: makeWrapper() }); + expect(screen.getByText(/Recently Played/)).toBeInTheDocument(); + expect(screen.getByText('Artists')).toBeInTheDocument(); + expect(screen.getByText('The Beatles')).toBeInTheDocument(); }); it('hides recently played sections when all are empty and not loading', () => { render(, { wrapper: makeWrapper() }); - expect(screen.queryByText('Recently Played Artists')).not.toBeInTheDocument(); - expect(screen.queryByText('Recently Played Albums')).not.toBeInTheDocument(); - expect(screen.queryByText('Recently Played Tracks')).not.toBeInTheDocument(); + expect(screen.queryByText('Recently Played')).not.toBeInTheDocument(); + expect(screen.queryByText('Artists')).not.toBeInTheDocument(); + expect(screen.queryByText('Albums')).not.toBeInTheDocument(); + }); + + it('shows user picker when availableUsers are present', () => { + const artist = { type: 'ARTIST', title: 'The Beatles' } as SonosItem; + mockUseRecentlyPlayed.mockReturnValue({ + ...defaultRecent, + artistItems: [artist], + availableUsers: ['joe', 'jane'], + currentUserId: 'joe', + }); + render(, { wrapper: makeWrapper() }); + expect(screen.getByRole('button', { name: /joe/i })).toBeInTheDocument(); }); it('shows SearchResults on /search path after query resolves', async () => { diff --git a/renderer/src/components/common/MediaCard.module.css b/renderer/src/components/common/MediaCard.module.css index 2219a06..22ace37 100644 --- a/renderer/src/components/common/MediaCard.module.css +++ b/renderer/src/components/common/MediaCard.module.css @@ -18,6 +18,22 @@ border-radius: 0; } +.artWrapCircular { + width: var(--card-size); + height: var(--card-size); + border-radius: 50%; + overflow: hidden; + position: relative; + background: var(--bg-1); + transition: box-shadow 0.3s ease; +} +.card:hover .artWrapCircular { + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.5), + 0 0 10px 3px rgba(255, 255, 255, 0.2), + 0 0 20px 4px rgba(255, 255, 255, 0.08); +} + .art { width: 100%; height: 100%; diff --git a/renderer/src/components/common/MediaCard.tsx b/renderer/src/components/common/MediaCard.tsx index 9f8ba7a..de4b7c6 100644 --- a/renderer/src/components/common/MediaCard.tsx +++ b/renderer/src/components/common/MediaCard.tsx @@ -7,15 +7,16 @@ interface Props { sub?: string | null; artUrl?: string | null; explicit?: boolean; + circular?: boolean; onAdd?: () => void; onOpen?: () => void; } -export function MediaCard({ name, sub, artUrl, explicit, onAdd, onOpen }: Props) { +export function MediaCard({ name, sub, artUrl, explicit, circular, onAdd, onOpen }: Props) { const cachedArt = useImage(artUrl); return (
-
+
{cachedArt ? :
diff --git a/renderer/src/hooks/useRecentlyPlayed.ts b/renderer/src/hooks/useRecentlyPlayed.ts index 0e5679c..07a54d4 100644 --- a/renderer/src/hooks/useRecentlyPlayed.ts +++ b/renderer/src/hooks/useRecentlyPlayed.ts @@ -1,12 +1,24 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueries } from '@tanstack/react-query'; +import { api } from '../lib/sonosApi'; import type { SonosItem } from '../types/sonos'; +import type { ArtistResponse } from '../types/ArtistResponse'; -function toArtistItem(a: RecentArtist): SonosItem { +function relativeDate(ts: number): string { + const diffMs = Date.now() - ts; + const days = Math.floor(diffMs / 86_400_000); + if (days === 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days} days ago`; + if (days < 14) return '1 week ago'; + return `${Math.floor(days / 7)} weeks ago`; +} + +function toArtistItem(a: RecentArtist, imageUrl?: string | null): SonosItem { return { type: 'ARTIST', title: a.artist, name: a.artist, - imageUrl: a.imageUrl, + imageUrl: imageUrl ?? a.imageUrl, resource: { type: 'ARTIST', id: { objectId: a.artistId, serviceId: a.serviceId, accountId: a.accountId }, @@ -22,28 +34,19 @@ function toAlbumItem(a: RecentAlbum): SonosItem { artist: a.artist, imageUrl: a.imageUrl, id: { objectId: a.albumId, serviceId: a.serviceId, accountId: a.accountId }, + summary: { content: `${a.artist} • ${relativeDate(a.lastPlayed)}` }, }; } -function toTrackItem(t: RecentTrack): SonosItem { - return { - type: 'ITEM_TRACK', - title: t.trackName, - name: t.trackName, - artist: t.artist, - imageUrl: t.imageUrl, - // Use albumId so useOpenItem's else branch navigates to the parent album - id: { objectId: t.albumId ?? '', serviceId: t.serviceId, accountId: t.accountId }, - }; -} - -export function useRecentlyPlayed() { - const { data: userId } = useQuery({ +export function useRecentlyPlayed(selectedUserId?: string) { + const { data: currentUserId } = useQuery({ queryKey: ['displayName'], queryFn: () => window.sonos.getDisplayName(), staleTime: Infinity, }); + const userId = selectedUserId ?? currentUserId ?? null; + const { data, isLoading } = useQuery({ queryKey: ['recentlyPlayed', userId], queryFn: async () => { @@ -54,10 +57,35 @@ export function useRecentlyPlayed() { staleTime: 5 * 60 * 1000, }); + const rawArtists = data?.artists ?? []; + + const artistImageQueries = useQueries({ + queries: rawArtists.slice(0, 9).map((a) => ({ + queryKey: ['artist-image', a.artistId], + queryFn: async (): Promise => { + if (!a.artistId || !a.serviceId || !a.accountId) return null; + const r = await api.browse.artist(a.artistId, { + serviceId: a.serviceId, + accountId: a.accountId, + muse2: true, + }); + if (r.error) return null; + return (r.data as ArtistResponse).images?.tile1x1 ?? null; + }, + staleTime: Infinity, + gcTime: 60 * 60 * 1000, + enabled: !!a.artistId && !!a.serviceId && !!a.accountId, + })), + }); + return { - artistItems: (data?.artists ?? []).map(toArtistItem), - albumItems: (data?.albums ?? []).map(toAlbumItem), - trackItems: (data?.tracks ?? []).filter(t => t.albumId).map(toTrackItem), + artistItems: rawArtists.slice(0, 9).map((a, i) => { + const q = artistImageQueries[i]; + return toArtistItem(a, q?.isSuccess ? (q.data ?? undefined) : undefined); + }), + albumItems: (data?.albums ?? []).map(toAlbumItem), + availableUsers: data?.availableUsers ?? [], + currentUserId: currentUserId ?? null, isLoading: !!userId && isLoading, }; } diff --git a/renderer/src/styles/CardRow.module.css b/renderer/src/styles/CardRow.module.css index 189fd97..25ec5fb 100644 --- a/renderer/src/styles/CardRow.module.css +++ b/renderer/src/styles/CardRow.module.css @@ -2,7 +2,8 @@ display: flex; gap: 14px; overflow-x: auto; - padding-bottom: 4px; + padding: 16px 2px 4px; + margin-top: -10px; } .cardRow::-webkit-scrollbar { height: 6px; diff --git a/renderer/src/styles/HomePanel.module.css b/renderer/src/styles/HomePanel.module.css index 9bc059d..5081b5a 100644 --- a/renderer/src/styles/HomePanel.module.css +++ b/renderer/src/styles/HomePanel.module.css @@ -28,3 +28,328 @@ text-align: center; padding: 56px 0; } + +.recentHeader { + margin-bottom: 20px; +} + +.userSep { + color: var(--text-3); +} + +.subSectionTitle { + font-size: 16px; + font-weight: 600; + color: var(--text-2); + letter-spacing: -0.2px; +} + +.recentEmpty { + color: var(--text-3); + font-size: 13px; + padding: 12px 0 32px; +} + +/* ── Skeletons ───────────────────────────────────────────────────────────── */ + +@keyframes skelPulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.55; } +} + +.skelTitle { + height: 18px; + width: 56px; + border-radius: 4px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; + margin-bottom: 2px; +} + +.skelAlbumArt { + width: 46px; + height: 46px; + border-radius: 5px; + flex-shrink: 0; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} + +.skelLine { + height: 12px; + width: 78%; + border-radius: 3px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} + +.skelLineShort { + height: 10px; + width: 50%; + border-radius: 3px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; + margin-top: 5px; +} + +.skelCircle { + width: min(100cqi, calc(100cqb - 18px)); + height: min(100cqi, calc(100cqb - 18px)); + border-radius: 50%; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} + +/* ── Recently Played grid layout ─────────────────────────────────────────── */ + +.recentGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: stretch; + margin-bottom: 40px; +} + +/* Album list */ + +.albumList { + display: flex; + flex-direction: column; + gap: 2px; +} + +.albumRow { + display: flex; + gap: 12px; + align-items: center; + padding: 7px 8px; + border-radius: 8px; + cursor: grab; + transition: background 0.15s; +} +.albumRow:active { cursor: grabbing; } +.albumRow:hover { background: rgba(255, 255, 255, 0.06); } +.albumRow:hover .albumAddBtn { opacity: 1; } + +.albumAddBtn { + margin-left: auto; + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + border: none; + cursor: pointer; + color: var(--text); + font-size: 18px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s, background 0.15s; +} +.albumAddBtn:hover { background: rgba(255, 255, 255, 0.22); } + +.albumArtWrap { + width: 46px; + height: 46px; + border-radius: 5px; + overflow: hidden; + flex-shrink: 0; + background: var(--bg-1); +} +.albumArt { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.albumArtPh { width: 100%; height: 100%; } + +.albumInfo { overflow: hidden; } +.albumName { + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.albumSub { + font-size: 11px; + color: var(--text-2); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Artist grid */ + +.artistGrid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.artistGridInner { + display: grid; + grid-template-columns: repeat(var(--grid-cols, 3), 1fr); + grid-template-rows: repeat(var(--grid-rows, 3), 1fr); + row-gap: 4px; + column-gap: 0; +} + +.artistCell { + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; +} + +.artistCircle { + position: relative; + width: min(100cqi, calc(100cqb - 18px)); + height: min(100cqi, calc(100cqb - 18px)); + flex-shrink: 0; + border-radius: 50%; + overflow: hidden; + background: var(--bg-1); + transition: box-shadow 0.3s ease; +} +.artistCell:hover .artistCircle { + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.5), + 0 0 10px 3px rgba(255, 255, 255, 0.2), + 0 0 20px 4px rgba(255, 255, 255, 0.08); +} + +.artistImg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.artistPh { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: var(--text-3); +} +.artistCellName { + font-size: 10px; + font-weight: 500; + color: var(--text-2); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + line-height: 1; +} + +.userDropdown { + position: relative; + display: inline-block; +} + +.userDropdownTrigger { + background: none; + border: none; + padding: 0; + margin: 0; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; + color: var(--text); + cursor: pointer; + display: inline-flex; + align-items: baseline; + gap: 4px; +} + +.userDropdownTrigger:disabled { + cursor: default; +} + +.chevron { + font-size: 15px; + opacity: 0.75; + line-height: 1; +} + +.chevronLocked { + font-size: 13px; + opacity: 0.6; + line-height: 1; +} + +.userDropdownList { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 50; + background: #1e1e1e; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 4px; + min-width: 160px; + list-style: none; + margin: 0; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.userDropdownItem, +.userDropdownItemActive { + display: block; + width: 100%; + background: none; + border: none; + text-align: left; + padding: 7px 12px; + border-radius: 5px; + font-size: 13px; + cursor: pointer; + color: var(--text-2); + transition: background 0.1s; +} + +.userDropdownItem:hover { + background: rgba(255, 255, 255, 0.07); + color: var(--text); +} + +.userDropdownItemActive { + color: var(--text); + font-weight: 600; +} + +.userDropdown[data-tooltip] { + cursor: default; +} + +.userDropdown[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 8px); + left: 0; + background: #242424; + color: var(--text-2); + font-size: 12px; + font-weight: 400; + letter-spacing: 0; + padding: 7px 11px; + border-radius: 7px; + white-space: nowrap; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45); + pointer-events: none; + z-index: 100; +} diff --git a/renderer/src/types/globals.d.ts b/renderer/src/types/globals.d.ts index f95bd19..3d2c587 100644 --- a/renderer/src/types/globals.d.ts +++ b/renderer/src/types/globals.d.ts @@ -226,6 +226,7 @@ interface RecentlyPlayedData { tracks: RecentTrack[]; artists: RecentArtist[]; albums: RecentAlbum[]; + availableUsers?: string[]; } interface SonosPreload { diff --git a/server/src/functions/recently-played.ts b/server/src/functions/recently-played.ts index 9cd48dd..5a1e84c 100644 --- a/server/src/functions/recently-played.ts +++ b/server/src/functions/recently-played.ts @@ -68,16 +68,26 @@ export async function recentlyPlayedHandler( const client = new CosmosClient(connStr); const container = client.database(dbName).container(ctrName); - const { resources } = await container.items.query({ - query: `SELECT c.eventType, c.trackName, c.artist, c.serviceId, c.accountId, - c.artistId, c.album, c.albumId, c.imageUrl, c.uri, c.timestamp - FROM c - WHERE c.userId = @userId AND c.timestamp >= @start`, - parameters: [ - { name: '@userId', value: userId }, - { name: '@start', value: startMs }, - ], - }).fetchAll(); + const usersStart = Date.now() - 30 * 24 * 60 * 60 * 1000; + const [eventsResult, usersResult] = await Promise.all([ + container.items.query({ + query: `SELECT c.eventType, c.trackName, c.artist, c.serviceId, c.accountId, + c.artistId, c.album, c.albumId, c.imageUrl, c.uri, c.timestamp + FROM c + WHERE c.userId = @userId AND c.timestamp >= @start`, + parameters: [ + { name: '@userId', value: userId }, + { name: '@start', value: startMs }, + ], + }).fetchAll(), + container.items.query({ + query: 'SELECT DISTINCT VALUE c.userId FROM c WHERE c.timestamp >= @usersStart', + parameters: [{ name: '@usersStart', value: usersStart }], + }).fetchAll(), + ]); + + const { resources } = eventsResult; + const availableUsers: string[] = usersResult.resources.filter(Boolean).sort(); // Sort most-recent first, then deduplicate — keeping the first (most recent) occurrence of each key. resources.sort((a, b) => b.timestamp - a.timestamp); @@ -143,10 +153,10 @@ export async function recentlyPlayedHandler( const artists = [...artistMap.values()].slice(0, 10); const albums = [...albumMap.values()].slice(0, 10); - context.log(`[recently-played] userId=${userId} tracks=${tracks.length} artists=${artists.length} albums=${albums.length}`); + context.log(`[recently-played] userId=${userId} tracks=${tracks.length} artists=${artists.length} albums=${albums.length} users=${availableUsers.length}`); return { - jsonBody: { tracks, artists, albums }, + jsonBody: { tracks, artists, albums, availableUsers }, headers: { 'Access-Control-Allow-Origin': '*' }, }; } catch (err) { From 852f15cba7e688c9528ef1622fdf73374edd5562 Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 8 May 2026 16:01:14 +0100 Subject: [PATCH 04/10] feat: user profile pages with clickable names throughout - Add /profile/:userName route with ProfilePanel showing recently played, all-time top tracks/artists/albums, and Queuedle tier + stat grid - Add useUserStats hook wrapping fetchStats alltime per user - Add TopNav profile button (Contact icon) for current user - Make attribution usernames in queue rows clickable to profile - Make all leaderboard usernames clickable to profile - Make all Queuedle panel leaderboard names clickable to profile - Add profile icon button to HomePanel user picker dropdown Co-Authored-By: Claude Sonnet 4.6 --- renderer/src/App.tsx | 2 + renderer/src/components/HomePanel.tsx | 13 +- renderer/src/components/LeaderboardPanel.tsx | 6 +- renderer/src/components/ProfilePanel.tsx | 174 ++++++++++++++++ renderer/src/components/TopNav.tsx | 11 + .../components/__tests__/HomePanel.test.tsx | 2 + .../__tests__/LeaderboardPanel.test.tsx | 16 +- .../components/queue/DraggableQueueRow.tsx | 12 +- .../__tests__/DraggableQueueRow.test.tsx | 3 +- .../src/components/queuedle/QueuedlePanel.tsx | 6 +- renderer/src/hooks/useUserStats.ts | 44 ++++ renderer/src/styles/HomePanel.module.css | 24 ++- .../src/styles/LeaderboardPanel.module.css | 18 ++ renderer/src/styles/ProfilePanel.module.css | 191 ++++++++++++++++++ renderer/src/styles/QueueSidebar.module.css | 11 + renderer/src/styles/Queuedle.module.css | 15 ++ 16 files changed, 533 insertions(+), 15 deletions(-) create mode 100644 renderer/src/components/ProfilePanel.tsx create mode 100644 renderer/src/hooks/useUserStats.ts create mode 100644 renderer/src/styles/ProfilePanel.module.css diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index 2068388..b006667 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -38,6 +38,7 @@ import { DisplayNameModal } from './components/DisplayNameModal'; import { FeedbackDialog } from './components/FeedbackDialog'; import { ChangelogDialog } from './components/ChangelogDialog'; import { LyricsPanel } from './components/LyricsPanel'; +import { ProfilePanel } from './components/ProfilePanel'; import { usePrefetchNextLyrics } from './hooks/usePrefetchNextLyrics'; import { ErrorBoundary } from './components/ErrorBoundary'; import { Splash } from './components/Splash'; @@ -462,6 +463,7 @@ useEffect(() => { } /> } /> } /> + } /> { export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading }: Props) { const queryClient = useQueryClient(); const location = useLocation(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const openItem = useOpenItem(); @@ -255,13 +257,20 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading }: Props) { {pickerOpen && !isPickerLocked && users.length > 1 && (
    {users.map((u) => ( -
  • +
  • +
  • ))}
diff --git a/renderer/src/components/LeaderboardPanel.tsx b/renderer/src/components/LeaderboardPanel.tsx index af0bb3d..337a30e 100644 --- a/renderer/src/components/LeaderboardPanel.tsx +++ b/renderer/src/components/LeaderboardPanel.tsx @@ -48,13 +48,13 @@ function makeDragItem(t: StatsTrack) { } export function LeaderboardPanel() { + const navigate = useNavigate(); const [period, setPeriod] = useState('week'); const [view, setView] = useState<'stats' | 'queuedle'>('stats'); const [selectedUser, setSelectedUser] = useState(null); const [rankInfoOpen, setRankInfoOpen] = useState(false); const { data, isLoading, error, refetch } = useStats(period, selectedUser ?? undefined); const rankings = useGameRankings(null, view === 'queuedle'); - const navigate = useNavigate(); const { resolveAndOpen } = useResolveAndOpen(); const maxUserCount = data?.topUsers?.[0]?.count ?? 1; @@ -132,7 +132,7 @@ export function LeaderboardPanel() { {i < 3 ? MEDALS[i] : {i + 1}} - {r.userName} +
{i < 3 ? MEDALS[i] : {i + 1}} - {u.userId} +
diff --git a/renderer/src/components/ProfilePanel.tsx b/renderer/src/components/ProfilePanel.tsx new file mode 100644 index 0000000..97e318f --- /dev/null +++ b/renderer/src/components/ProfilePanel.tsx @@ -0,0 +1,174 @@ +import { useParams } from 'react-router-dom'; +import { useRecentlyPlayed } from '../hooks/useRecentlyPlayed'; +import { useUserStats } from '../hooks/useUserStats'; +import { useGameRankings } from '../hooks/useDailyGame'; +import { getGameRankIcon } from '../lib/gameRankAssets'; +import { useOpenItem } from '../hooks/useOpenItem'; +import { CardRow } from './CardRow'; +import { MediaRow } from './common/MediaRow'; +import type { SonosItem } from '../types/sonos'; +import styles from '../styles/ProfilePanel.module.css'; + +function getAvatarStyle(name: string): React.CSSProperties { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return { + background: `linear-gradient(135deg, hsl(${hue},55%,38%), hsl(${(hue + 40) % 360},60%,28%))`, + }; +} + +interface Props { + onAddToQueue: (item: SonosItem) => void; +} + +export function ProfilePanel({ onAddToQueue }: Props) { + const { userName } = useParams<{ userName: string }>(); + const openItem = useOpenItem(); + + const { artistItems: recentArtists, albumItems: recentAlbums, isLoading: recentLoading } = + useRecentlyPlayed(userName); + + const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, isLoading: statsLoading } = + useUserStats(userName); + + const { data: rankings } = useGameRankings(userName, !!userName); + const ranking = rankings?.find(r => r.userName === userName) ?? null; + + if (!userName) return null; + + const tierIcon = ranking ? getGameRankIcon(ranking.tierKey) : null; + const hasRecent = recentLoading || recentArtists.length > 0 || recentAlbums.length > 0; + + return ( +
+ {/* ── Header ──────────────────────────────────────────────────────── */} +
+
+ {userName[0].toUpperCase()} +
+
+

{userName}

+
+ {ranking && ( + + {tierIcon && } + {ranking.isProvisional ? 'Provisional' : ranking.tierName} + + )} + {totalEvents > 0 && ( + + {totalEvents.toLocaleString()} total plays + + )} +
+
+
+ + {/* ── Recently Played ──────────────────────────────────────────────── */} + {hasRecent && ( +
+

Recently Played — Last 7 Days

+ {recentLoading || recentArtists.length > 0 ? ( + <> +

Artists

+ + + ) : null} + {recentLoading || recentAlbums.length > 0 ? ( + <> +

Albums

+ + + ) : null} + {!recentLoading && recentArtists.length === 0 && recentAlbums.length === 0 && ( +

Nothing queued in the last 7 days

+ )} +
+ )} + + {/* ── Top Played All Time ──────────────────────────────────────────── */} +
+

Top Played — All Time

+ + {(statsLoading || topTracks.length > 0) && ( + <> +

Tracks

+
+ {statsLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : topTracks.slice(0, 10).map((t, i) => ( + {t.count}×} + /> + )) + } +
+ + )} + + {(statsLoading || topArtists.length > 0) && ( + <> +

Artists

+ + + )} + + {(statsLoading || topAlbums.length > 0) && ( + <> +

Albums

+ + + )} + + {!statsLoading && topTracks.length === 0 && topArtists.length === 0 && topAlbums.length === 0 && ( +

No plays recorded yet

+ )} +
+ + {/* ── Queuedle Stats ───────────────────────────────────────────────── */} +
+

Queuedle

+ {ranking ? ( + <> +
+ + {tierIcon && } + {ranking.isProvisional ? 'Provisional' : ranking.tierName} + +
+
+
+ {ranking.gamesPlayed} + Games +
+
+ {ranking.averageTotal.toFixed(1)} + Avg Score +
+
+ {ranking.bestTotal} + Best Score +
+
+ {ranking.averagePercent.toFixed(0)}% + Avg % +
+
+ + ) : ( +

No Queuedle data yet

+ )} +
+
+ ); +} diff --git a/renderer/src/components/TopNav.tsx b/renderer/src/components/TopNav.tsx index 6082d27..4f5fa26 100644 --- a/renderer/src/components/TopNav.tsx +++ b/renderer/src/components/TopNav.tsx @@ -8,6 +8,7 @@ import { Search, X, User, + Contact, RefreshCw, Gamepad2, Lightbulb, @@ -263,6 +264,16 @@ export function TopNav({
)} + {displayName && ( + + )} + diff --git a/renderer/src/components/__tests__/HomePanel.test.tsx b/renderer/src/components/__tests__/HomePanel.test.tsx index 36d7494..7268ac7 100644 --- a/renderer/src/components/__tests__/HomePanel.test.tsx +++ b/renderer/src/components/__tests__/HomePanel.test.tsx @@ -10,9 +10,11 @@ const mockBrowseContainer = vi.fn(); const mockUseLocation = vi.fn(); const mockUseSearchParams = vi.fn(); +const mockNavigate = vi.fn(); vi.mock('react-router-dom', () => ({ useLocation: () => mockUseLocation(), useSearchParams: () => mockUseSearchParams(), + useNavigate: () => mockNavigate, })); vi.mock('../../hooks/useOpenItem', () => ({ useOpenItem: () => vi.fn() })); diff --git a/renderer/src/components/__tests__/LeaderboardPanel.test.tsx b/renderer/src/components/__tests__/LeaderboardPanel.test.tsx index a636df1..9c73318 100644 --- a/renderer/src/components/__tests__/LeaderboardPanel.test.tsx +++ b/renderer/src/components/__tests__/LeaderboardPanel.test.tsx @@ -296,33 +296,39 @@ describe('LeaderboardPanel', () => { }); describe('user drill-down', () => { + function clickAliceRow() { + // The username button navigates to profile (stopPropagation). + // Clicking the row div (via the medal/rank span) drills into that user's stats. + fireEvent.click(screen.getByRole('button', { name: 'alice' }).closest('div[class]')!); + } + it('navigates to user stats when a user row is clicked', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); expect(mockUseStats).toHaveBeenCalledWith('week', 'alice'); }); it('shows the selected username as the title', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); expect(screen.getByRole('heading', { name: 'alice' })).toBeInTheDocument(); }); it('hides the top queuers section when a user is selected', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); expect(screen.queryByText('Top queuers')).toBeNull(); }); it('shows a back button when a user is selected', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); expect(screen.getByTitle('Back')).toBeInTheDocument(); }); it('returns to leaderboard when back button is clicked', () => { render(); - fireEvent.click(screen.getByText('alice')); + clickAliceRow(); fireEvent.click(screen.getByTitle('Back')); expect(screen.getByText('Leaderboard')).toBeInTheDocument(); diff --git a/renderer/src/components/queue/DraggableQueueRow.tsx b/renderer/src/components/queue/DraggableQueueRow.tsx index 317400f..a850937 100644 --- a/renderer/src/components/queue/DraggableQueueRow.tsx +++ b/renderer/src/components/queue/DraggableQueueRow.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from 'react-router-dom'; import { useImage } from '../../hooks/useImage'; import { useOpenItem } from '../../hooks/useOpenItem'; import { useQueueTrack } from '../../hooks/useQueueTrack'; @@ -40,6 +41,7 @@ export function DraggableQueueRow({ : isPlayingByObjectId; const cachedArt = useImage(artUrl); const openItem = useOpenItem(); + const navigate = useNavigate(); const name = item.track.title; return ( @@ -95,7 +97,15 @@ export function DraggableQueueRow({ )} {attribution && ( -
by {attribution.user}
+
+ by{' '} + +
)}
{timeToPlay !== undefined && ( diff --git a/renderer/src/components/queue/__tests__/DraggableQueueRow.test.tsx b/renderer/src/components/queue/__tests__/DraggableQueueRow.test.tsx index 8e6c75b..af08c88 100644 --- a/renderer/src/components/queue/__tests__/DraggableQueueRow.test.tsx +++ b/renderer/src/components/queue/__tests__/DraggableQueueRow.test.tsx @@ -10,6 +10,7 @@ const { mockSkipToTrack } = vi.hoisted(() => ({ vi.mock('../../../hooks/useImage', () => ({ useImage: () => null })); vi.mock('../../../hooks/useOpenItem', () => ({ useOpenItem: () => vi.fn() })); vi.mock('../../../providers', () => ({ getActiveProvider: () => ({ skipToTrack: mockSkipToTrack }) })); +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); const mockUseQueueTrack = vi.fn(); vi.mock('../../../hooks/useQueueTrack', () => ({ @@ -123,7 +124,7 @@ describe('DraggableQueueRow', () => { it('shows attribution when provided', () => { const attribution: AttributionEntry = { user: 'alice', timestamp: 0, trackName: 'T', artist: 'A' }; render(); - expect(screen.getByText('by alice')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'alice' })).toBeInTheDocument(); }); it('shows art placeholder when no image is cached', () => { diff --git a/renderer/src/components/queuedle/QueuedlePanel.tsx b/renderer/src/components/queuedle/QueuedlePanel.tsx index de84cb0..1c730b1 100644 --- a/renderer/src/components/queuedle/QueuedlePanel.tsx +++ b/renderer/src/components/queuedle/QueuedlePanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useDailyGame, useSubmitGameScore, @@ -36,6 +37,7 @@ function londonDateToday(): string { } export function QueuedlePanel() { + const navigate = useNavigate(); const [selectedDate, setSelectedDate] = useState(null); const [leaderboardTab, setLeaderboardTab] = useState('today'); const todayId = useMemo(() => londonDateToday(), []); @@ -280,7 +282,7 @@ export function QueuedlePanel() { {scores.slice(0, 10).map((s, i) => (
{i < 3 ? ['🥇', '🥈', '🥉'][i] : i + 1} - {s.userName} + {s.mainScore}/{currentGame.questions.length} · {s.bonusScore}/{currentGame.questions.length} @@ -312,7 +314,7 @@ export function QueuedlePanel() { rankedRows.slice(0, 10).map((ranked, i) => (
{i + 1} - {ranked.userName} + {ranked.gamesPlayed} {ranked.gamesPlayed === 1 ? 'game' : 'games'} ·{' '} diff --git a/renderer/src/hooks/useUserStats.ts b/renderer/src/hooks/useUserStats.ts new file mode 100644 index 0000000..025dc70 --- /dev/null +++ b/renderer/src/hooks/useUserStats.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import type { SonosItem } from '../types/sonos'; + +function toArtistItem(a: StatsArtist): SonosItem { + return { + type: 'ARTIST', + title: a.artist, + name: a.artist, + imageUrl: a.imageUrl, + resource: { + type: 'ARTIST', + id: { objectId: a.artistId, serviceId: a.serviceId, accountId: a.accountId }, + }, + }; +} + +function toAlbumItem(a: StatsAlbum): SonosItem { + return { + type: 'ITEM_ALBUM', + title: a.album, + name: a.album, + artist: a.artist, + imageUrl: a.imageUrl, + id: { objectId: a.albumId, serviceId: a.serviceId, accountId: a.accountId }, + summary: { content: a.artist }, + }; +} + +export function useUserStats(userName: string | undefined) { + const { data, isLoading } = useQuery({ + queryKey: ['stats', 'alltime', userName ?? null], + queryFn: () => window.sonos.fetchStats('alltime', userName), + enabled: !!userName, + staleTime: 5 * 60_000, + }); + + return { + topTracks: data?.topTracks ?? [], + artistItems: (data?.topArtists ?? []).slice(0, 12).map(toArtistItem), + albumItems: (data?.topAlbums ?? []).slice(0, 12).map(toAlbumItem), + totalEvents: data?.totalEvents ?? 0, + isLoading: !!userName && isLoading, + }; +} diff --git a/renderer/src/styles/HomePanel.module.css b/renderer/src/styles/HomePanel.module.css index 5081b5a..8e30d52 100644 --- a/renderer/src/styles/HomePanel.module.css +++ b/renderer/src/styles/HomePanel.module.css @@ -306,10 +306,16 @@ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); } +.userDropdownLi { + display: flex; + align-items: center; +} + .userDropdownItem, .userDropdownItemActive { display: block; - width: 100%; + flex: 1; + min-width: 0; background: none; border: none; text-align: left; @@ -321,6 +327,22 @@ transition: background 0.1s; } +.profileLinkBtn { + background: none; + border: none; + color: var(--text-3); + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s, color 0.12s, background 0.12s; +} +.userDropdownLi:hover .profileLinkBtn { opacity: 1; } +.profileLinkBtn:hover { color: var(--text); background: rgba(255, 255, 255, 0.07); } + .userDropdownItem:hover { background: rgba(255, 255, 255, 0.07); color: var(--text); diff --git a/renderer/src/styles/LeaderboardPanel.module.css b/renderer/src/styles/LeaderboardPanel.module.css index 1c21c97..421893c 100644 --- a/renderer/src/styles/LeaderboardPanel.module.css +++ b/renderer/src/styles/LeaderboardPanel.module.css @@ -298,6 +298,24 @@ white-space: nowrap; } +.userNameBtn { + font-size: 14px; + font-weight: 500; + color: var(--text); + width: 120px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: none; + border: none; + padding: 0; + text-align: left; + font-family: inherit; + cursor: pointer; +} +.userNameBtn:hover { text-decoration: underline; } + .barWrap { flex: 1; height: 6px; diff --git a/renderer/src/styles/ProfilePanel.module.css b/renderer/src/styles/ProfilePanel.module.css new file mode 100644 index 0000000..59e3cc5 --- /dev/null +++ b/renderer/src/styles/ProfilePanel.module.css @@ -0,0 +1,191 @@ +.panel { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: calc(var(--nav-h) + 24px) 28px calc(var(--player-h) + 24px); +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 40px; + padding-bottom: 28px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.avatar { + width: 72px; + height: 72px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + font-weight: 700; + color: #fff; + flex-shrink: 0; + letter-spacing: -1px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); +} + +.headerInfo { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.userName { + font-size: 26px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.5px; + line-height: 1.1; +} + +.headerMeta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.tierBadge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 500; + color: var(--text-2); + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; + padding: 4px 10px 4px 5px; +} + +.tierIcon { + width: 18px; + height: 18px; + object-fit: contain; +} + +.statChip { + font-size: 12px; + color: var(--text-3); +} + +.statChipValue { + font-weight: 600; + color: var(--text-2); + font-variant-numeric: tabular-nums; +} + +/* ── Sections ────────────────────────────────────────────────────────────── */ + +.section { + margin-bottom: 44px; +} + +.sectionTitle { + font-size: 22px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.4px; + margin-bottom: 4px; +} + +.subSectionTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-2); + letter-spacing: -0.1px; + margin-top: 20px; + margin-bottom: 0; +} + +.emptyMsg { + font-size: 13px; + color: var(--text-3); + padding: 16px 0 8px; +} + +/* ── Top Tracks list ─────────────────────────────────────────────────────── */ + +.trackList { + display: flex; + flex-direction: column; + margin-top: 4px; +} + +.countBadge { + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--text-3); + white-space: nowrap; +} + +/* ── Queuedle stats grid ─────────────────────────────────────────────────── */ + +.queuedleMeta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +.queuedleStats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.statCell { + background: var(--bg-2, rgba(255, 255, 255, 0.04)); + border-radius: 10px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.statCellValue { + font-size: 22px; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; + letter-spacing: -0.5px; + line-height: 1; +} + +.statCellLabel { + font-size: 10px; + color: var(--text-3); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.6px; +} + +/* ── Skeletons ───────────────────────────────────────────────────────────── */ + +@keyframes skelPulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.55; } +} + +.skelAvatar { + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--bg-1); + flex-shrink: 0; + animation: skelPulse 1.8s ease-in-out infinite; +} + +.skelBlock { + border-radius: 4px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} diff --git a/renderer/src/styles/QueueSidebar.module.css b/renderer/src/styles/QueueSidebar.module.css index f63ead6..3fc0bfa 100644 --- a/renderer/src/styles/QueueSidebar.module.css +++ b/renderer/src/styles/QueueSidebar.module.css @@ -296,6 +296,17 @@ text-overflow: ellipsis; } +.attributionUser { + background: none; + border: none; + padding: 0; + font-size: inherit; + font-family: inherit; + color: inherit; + cursor: pointer; +} +.attributionUser:hover { color: var(--text); text-decoration: underline; } + .timeToPlay { font-size: 10px; color: var(--text-3); diff --git a/renderer/src/styles/Queuedle.module.css b/renderer/src/styles/Queuedle.module.css index 8d0aa99..eb74128 100644 --- a/renderer/src/styles/Queuedle.module.css +++ b/renderer/src/styles/Queuedle.module.css @@ -966,6 +966,21 @@ text-overflow: ellipsis; white-space: nowrap; } +.scoreNameBtn { + background: none; + border: none; + padding: 0; + font-size: 14px; + font-family: inherit; + color: var(--text); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + text-align: left; +} +.scoreNameBtn:hover { text-decoration: underline; } .scoreBreakdown { font-size: 11px; color: var(--text-3); From a373c748a7c41b0bdad1ed28a4ee20300c720c58 Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 8 May 2026 16:10:32 +0100 Subject: [PATCH 05/10] style: promote Queuedle rank to prominent header hero on profile page Co-Authored-By: Claude Sonnet 4.6 --- renderer/src/components/ProfilePanel.tsx | 35 +++++++++--------- renderer/src/styles/ProfilePanel.module.css | 40 +++++++++++++++++++-- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/renderer/src/components/ProfilePanel.tsx b/renderer/src/components/ProfilePanel.tsx index 97e318f..bc3c516 100644 --- a/renderer/src/components/ProfilePanel.tsx +++ b/renderer/src/components/ProfilePanel.tsx @@ -52,20 +52,27 @@ export function ProfilePanel({ onAddToQueue }: Props) {

{userName}

-
- {ranking && ( - - {tierIcon && } + {totalEvents > 0 && ( + + {totalEvents.toLocaleString()} total plays + + )} +
+ {ranking && ( +
+ {tierIcon && } +
+ {ranking.isProvisional ? 'Provisional' : ranking.tierName} - )} - {totalEvents > 0 && ( - - {totalEvents.toLocaleString()} total plays - - )} + {!ranking.isProvisional && ( + + {ranking.averagePercent.toFixed(0)}% avg · {ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} + + )} +
-
+ )}
{/* ── Recently Played ──────────────────────────────────────────────── */} @@ -140,12 +147,6 @@ export function ProfilePanel({ onAddToQueue }: Props) {

Queuedle

{ranking ? ( <> -
- - {tierIcon && } - {ranking.isProvisional ? 'Provisional' : ranking.tierName} - -
{ranking.gamesPlayed} diff --git a/renderer/src/styles/ProfilePanel.module.css b/renderer/src/styles/ProfilePanel.module.css index 59e3cc5..3550535 100644 --- a/renderer/src/styles/ProfilePanel.module.css +++ b/renderer/src/styles/ProfilePanel.module.css @@ -129,11 +129,45 @@ /* ── Queuedle stats grid ─────────────────────────────────────────────────── */ -.queuedleMeta { +.rankHero { display: flex; align-items: center; - gap: 10px; - margin-bottom: 16px; + gap: 16px; + margin-left: auto; + padding: 14px 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 14px; + flex-shrink: 0; +} + +.rankHeroIcon { + width: 80px; + height: 80px; + object-fit: contain; + flex-shrink: 0; + filter: drop-shadow(0 4px 16px rgba(0, 0, 0, 0.5)); +} + +.rankHeroText { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.rankHeroName { + font-size: 24px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.5px; + line-height: 1.1; +} + +.rankHeroPercent { + font-size: 13px; + color: var(--text-3); + letter-spacing: 0; } .queuedleStats { From ff39e76638229d55c8ffd13c67064fc409b490dc Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 8 May 2026 18:46:23 +0100 Subject: [PATCH 06/10] feat: add user playlists and track context menu - Implement comprehensive playlist management with dedicated UI panel. - Allow users to create, view, add tracks to, and manage their own playlists. - Introduce a global context menu for tracks to play next, queue, or add to playlist. - Integrate track context menu across album, artist, queue, and search track lists. - Display user playlists on home and profile panels with a create playlist option. - Add backend APIs and storage for playlist data and cover images. --- renderer/src/App.tsx | 9 +- renderer/src/components/HomePanel.tsx | 66 +++- renderer/src/components/PlaylistPanel.tsx | 290 ++++++++++++++ renderer/src/components/ProfilePanel.tsx | 78 +++- .../src/components/album/AlbumTrackRow.tsx | 15 + .../src/components/artist/HeroTrackRow.tsx | 16 + renderer/src/components/artist/TopSongRow.tsx | 16 + .../src/components/common/ContextMenu.tsx | 354 ++++++++++++++++++ renderer/src/components/common/MediaRow.tsx | 4 +- .../components/queue/DraggableQueueRow.tsx | 15 + .../src/components/search/SearchResults.tsx | 24 +- renderer/src/hooks/usePlaylists.ts | 48 +++ renderer/src/styles/ContextMenu.module.css | 90 +++++ renderer/src/styles/HomePanel.module.css | 71 ++++ renderer/src/styles/PlaylistPanel.module.css | 348 +++++++++++++++++ renderer/src/styles/ProfilePanel.module.css | 82 +++- renderer/src/test/setup.ts | 6 + renderer/src/types/globals.d.ts | 36 ++ server/azuredeploy.json | 23 ++ server/src/functions/playlist-add-track.ts | 80 ++++ server/src/functions/playlist-create.ts | 65 ++++ server/src/functions/playlist-get.ts | 47 +++ server/src/functions/playlist-join.ts | 84 +++++ server/src/functions/playlist-list.ts | 82 ++++ server/src/functions/playlist-upload-image.ts | 109 ++++++ src/main.ts | 73 ++++ src/preload.ts | 12 + 27 files changed, 2136 insertions(+), 7 deletions(-) create mode 100644 renderer/src/components/PlaylistPanel.tsx create mode 100644 renderer/src/components/common/ContextMenu.tsx create mode 100644 renderer/src/hooks/usePlaylists.ts create mode 100644 renderer/src/styles/ContextMenu.module.css create mode 100644 renderer/src/styles/PlaylistPanel.module.css create mode 100644 server/src/functions/playlist-add-track.ts create mode 100644 server/src/functions/playlist-create.ts create mode 100644 server/src/functions/playlist-get.ts create mode 100644 server/src/functions/playlist-join.ts create mode 100644 server/src/functions/playlist-list.ts create mode 100644 server/src/functions/playlist-upload-image.ts diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index b006667..8ce5205 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -39,6 +39,8 @@ import { FeedbackDialog } from './components/FeedbackDialog'; import { ChangelogDialog } from './components/ChangelogDialog'; import { LyricsPanel } from './components/LyricsPanel'; import { ProfilePanel } from './components/ProfilePanel'; +import { PlaylistPanel } from './components/PlaylistPanel'; +import { ContextMenuProvider } from './components/common/ContextMenu'; import { usePrefetchNextLyrics } from './hooks/usePrefetchNextLyrics'; import { ErrorBoundary } from './components/ErrorBoundary'; import { Splash } from './components/Splash'; @@ -414,6 +416,7 @@ useEffect(() => { const splashReady = isAuthed && groups.length > 0 && !ytmLoading; return ( +
{ onAddToQueue={handleAddToQueue} ytm={ytm} ytmLoading={ytmLoading} + displayName={displayName} /> } /> @@ -454,6 +458,7 @@ useEffect(() => { onAddToQueue={handleAddToQueue} ytm={ytm} ytmLoading={ytmLoading} + displayName={displayName} /> } /> @@ -463,7 +468,8 @@ useEffect(() => { } /> } /> } /> - } /> + } /> + } /> { {feedbackOpen && setFeedbackOpen(false)} />} {changelogOpen && setChangelogOpen(false)} />}
+
); } diff --git a/renderer/src/components/HomePanel.tsx b/renderer/src/components/HomePanel.tsx index fb1a5a5..8f4cae7 100644 --- a/renderer/src/components/HomePanel.tsx +++ b/renderer/src/components/HomePanel.tsx @@ -15,6 +15,7 @@ import { } from '../lib/itemHelpers'; import { useOpenItem } from '../hooks/useOpenItem'; import { useRecentlyPlayed } from '../hooks/useRecentlyPlayed'; +import { useMyPlaylists } from '../hooks/usePlaylists'; import { useDailyGame, useMyScore } from '../hooks/useDailyGame'; import { useImage } from '../hooks/useImage'; import type { ServiceSearch } from '../types/ServiceSearch'; @@ -22,6 +23,7 @@ import { albumQueryOptions } from '../hooks/useAlbumBrowse'; import { artistQueryOptions } from '../hooks/useArtistBrowse'; import { CardRow } from './CardRow'; import { SearchResults } from './search/SearchResults'; +import { CreatePlaylistDialog } from './common/ContextMenu'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/HomePanel.module.css'; @@ -82,6 +84,7 @@ interface Props { onAddToQueue: (item: SonosItem) => void; ytm: YtmSections | undefined; ytmLoading: boolean; + displayName?: string | null; } export interface YtmSections { @@ -135,7 +138,39 @@ export async function fetchYtmSections(): Promise { }; } -export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading }: Props) { +function getPlaylistColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return `linear-gradient(135deg, hsl(${hue},45%,28%), hsl(${(hue + 50) % 360},50%,20%))`; +} + +function PlaylistCard({ pl, displayName, onClick }: { pl: PlaylistMeta; displayName?: string | null; onClick: () => void }) { + const art = useImage(pl.imageUrl ?? null); + return ( + + ); +} + +export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName }: Props) { const queryClient = useQueryClient(); const location = useLocation(); const navigate = useNavigate(); @@ -145,6 +180,9 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading }: Props) { const view = location.pathname === '/search' ? 'search' : 'home'; const activeSearch = searchParams.get('q') ?? ''; + const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(displayName); + + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(undefined); const [pickerOpen, setPickerOpen] = useState(false); const pickerRef = useRef(null); @@ -228,6 +266,25 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading }: Props) {
+ {displayName && (ownedPlaylists.length > 0 || joinedPlaylists.length > 0) && ( +
+
+

Playlists

+ +
+
+ {[...ownedPlaylists, ...joinedPlaylists].map((pl) => ( + navigate(`/playlist/${pl.id}`)} + /> + ))} +
+
+ )} + {hasRecent && ( <>
@@ -369,6 +426,13 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading }: Props) { )} + {createPlaylistOpen && ( + setCreatePlaylistOpen(false)} + /> + )}
); } diff --git a/renderer/src/components/PlaylistPanel.tsx b/renderer/src/components/PlaylistPanel.tsx new file mode 100644 index 0000000..eb43d7d --- /dev/null +++ b/renderer/src/components/PlaylistPanel.tsx @@ -0,0 +1,290 @@ +import { useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { usePlaylist } from '../hooks/usePlaylists'; +import { useImage } from '../hooks/useImage'; +import { useTrackContextMenu } from './common/ContextMenu'; +import type { SonosItem } from '../types/sonos'; +import styles from '../styles/PlaylistPanel.module.css'; + +interface Props { + displayName: string | null | undefined; + onAddToQueue: (item: SonosItem, position?: number) => void; +} + +function getPlaylistColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return `linear-gradient(135deg, hsl(${hue},45%,30%), hsl(${(hue + 50) % 360},50%,22%))`; +} + +function trackToSonosItem(t: PlaylistTrack): SonosItem { + return { + type: 'TRACK', + title: t.trackName, + artist: t.artist, + imageUrl: t.imageUrl ?? undefined, + id: { objectId: t.uri, serviceId: t.serviceId, accountId: t.accountId }, + }; +} + +function TrackRow({ + track, + index, + isOwner, + onAdd, + onRemove, + onContextMenu, + onDragStart, +}: { + track: PlaylistTrack; + index: number; + isOwner: boolean; + onAdd: () => void; + onRemove: () => void; + onContextMenu: (e: React.MouseEvent) => void; + onDragStart: (e: React.DragEvent) => void; +}) { + const art = useImage(track.imageUrl ?? null); + const navigate = useNavigate(); + + return ( +
+ {index + 1} +
+ {art + ? + :
+ } +
+
+
{track.trackName}
+
{track.artist}
+
+
+ {track.addedBy && ( + + )} +
+
+ + {isOwner && ( + + )} +
+
+ ); +} + +export function PlaylistPanel({ displayName, onAddToQueue }: Props) { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showTrackMenu } = useTrackContextMenu(); + const { data: playlist, isLoading } = usePlaylist(id); + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const coverArtSrc = useImage(playlist?.imageUrl ?? null); + + if (!id) return null; + + const isOwner = playlist?.owner === displayName; + const isMember = playlist?.members.includes(displayName ?? '') ?? false; + + async function handlePlayAll() { + if (!playlist?.tracks.length) return; + for (const t of playlist.tracks) { + onAddToQueue(trackToSonosItem(t), -1); + } + } + + async function handleJoin() { + if (!id) return; + await window.sonos.joinPlaylist(id, isMember ? 'leave' : 'join'); + queryClient.invalidateQueries({ queryKey: ['playlist', id] }); + } + + async function handleImageUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file || !id) return; + if (file.size > 5 * 1024 * 1024) { alert('Image must be under 5 MB'); return; } + setUploading(true); + try { + const buffer = await file.arrayBuffer(); + const result = await window.sonos.uploadPlaylistImage(id, buffer, file.type); + if (result && 'imageUrl' in result) { + queryClient.invalidateQueries({ queryKey: ['playlist', id] }); + } + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + async function handleRemoveTrack(index: number) { + if (!playlist || !id) return; + const updated: PlaylistDoc = { + ...playlist, + tracks: playlist.tracks.filter((_, i) => i !== index), + }; + queryClient.setQueryData(['playlist', id], updated); + } + + return ( +
+ {/* ── Header ── */} +
+
+
isOwner && fileInputRef.current?.click()} + title={isOwner ? 'Change image' : undefined} + > + {coverArtSrc + ? + : (!isLoading && playlist ? playlist.name[0].toUpperCase() : '') + } + {isOwner && ( +
+ {uploading ? '…' : '📷'} +
+ )} +
+ +
+ Playlist + {isLoading ? ( +
+ ) : playlist ? ( + <> +

{playlist.name}

+
+ + · + {playlist.isPublic ? '🌐 Public' : '🔒 Private'} + {playlist.isPublic && ( + <> + · + {playlist.members.length} member{playlist.members.length !== 1 ? 's' : ''} + + )} + · + {playlist.tracks.length} track{playlist.tracks.length !== 1 ? 's' : ''} +
+
+ {playlist.tracks.length > 0 && ( + + )} + {playlist.isPublic && !isOwner && ( + + )} +
+ + ) : ( +
Playlist not found
+ )} +
+
+
+ + {/* ── Track table ── */} +
+ {isLoading ? ( + <> +
+ #TitleAdded by +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} + + ) : playlist && playlist.tracks.length === 0 ? ( +

No tracks yet — right-click any track and choose "Add to playlist"

+ ) : playlist ? ( + <> +
+ # + + Title + Added by + +
+ {playlist.tracks.map((t, i) => { + const sonosItem = trackToSonosItem(t); + return ( + onAddToQueue(sonosItem, -1)} + onRemove={() => handleRemoveTrack(i)} + onContextMenu={e => showTrackMenu({ track: t, sonosItem }, e)} + onDragStart={e => { + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify([sonosItem])); + }} + /> + ); + })} + + ) : null} +
+ + {/* ── Members section ── */} + {playlist?.isPublic && isOwner && playlist.members.length > 1 && ( +
+

Members

+
+ {playlist.members.map((m) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/renderer/src/components/ProfilePanel.tsx b/renderer/src/components/ProfilePanel.tsx index bc3c516..871df96 100644 --- a/renderer/src/components/ProfilePanel.tsx +++ b/renderer/src/components/ProfilePanel.tsx @@ -1,14 +1,50 @@ -import { useParams } from 'react-router-dom'; +import { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; import { useRecentlyPlayed } from '../hooks/useRecentlyPlayed'; import { useUserStats } from '../hooks/useUserStats'; import { useGameRankings } from '../hooks/useDailyGame'; +import { useMyPlaylists } from '../hooks/usePlaylists'; import { getGameRankIcon } from '../lib/gameRankAssets'; import { useOpenItem } from '../hooks/useOpenItem'; +import { useImage } from '../hooks/useImage'; import { CardRow } from './CardRow'; import { MediaRow } from './common/MediaRow'; +import { CreatePlaylistDialog } from './common/ContextMenu'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/ProfilePanel.module.css'; +function getPlaylistColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return `linear-gradient(135deg, hsl(${hue},45%,28%), hsl(${(hue + 50) % 360},50%,20%))`; +} + +function PlaylistCard({ pl, onClick }: { pl: PlaylistMeta; onClick: () => void }) { + const art = useImage(pl.imageUrl ?? null); + return ( + + ); +} + function getAvatarStyle(name: string): React.CSSProperties { let hash = 0; for (let i = 0; i < name.length; i++) { @@ -23,11 +59,14 @@ function getAvatarStyle(name: string): React.CSSProperties { interface Props { onAddToQueue: (item: SonosItem) => void; + displayName?: string | null; } -export function ProfilePanel({ onAddToQueue }: Props) { +export function ProfilePanel({ onAddToQueue, displayName }: Props) { const { userName } = useParams<{ userName: string }>(); + const navigate = useNavigate(); const openItem = useOpenItem(); + const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); const { artistItems: recentArtists, albumItems: recentAlbums, isLoading: recentLoading } = useRecentlyPlayed(userName); @@ -38,6 +77,12 @@ export function ProfilePanel({ onAddToQueue }: Props) { const { data: rankings } = useGameRankings(userName, !!userName); const ranking = rankings?.find(r => r.userName === userName) ?? null; + const isOwnProfile = !!displayName && displayName === userName; + const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(userName); + const visiblePlaylists = isOwnProfile + ? [...ownedPlaylists, ...joinedPlaylists] + : ownedPlaylists.filter(p => p.isPublic); + if (!userName) return null; const tierIcon = ranking ? getGameRankIcon(ranking.tierKey) : null; @@ -75,6 +120,27 @@ export function ProfilePanel({ onAddToQueue }: Props) { )}
+ {/* ── Playlists ────────────────────────────────────────────────────── */} + {(visiblePlaylists.length > 0 || isOwnProfile) && ( +
+
+

Playlists

+ {isOwnProfile && ( + + )} +
+
+ {visiblePlaylists.map((pl) => ( + navigate(`/playlist/${pl.id}`)} + /> + ))} +
+
+ )} + {/* ── Recently Played ──────────────────────────────────────────────── */} {hasRecent && (
@@ -170,6 +236,14 @@ export function ProfilePanel({ onAddToQueue }: Props) {

No Queuedle data yet

)}
+ + {createPlaylistOpen && ( + setCreatePlaylistOpen(false)} + /> + )}
); } diff --git a/renderer/src/components/album/AlbumTrackRow.tsx b/renderer/src/components/album/AlbumTrackRow.tsx index 0a555d5..c28e687 100644 --- a/renderer/src/components/album/AlbumTrackRow.tsx +++ b/renderer/src/components/album/AlbumTrackRow.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom'; import { useImage } from '../../hooks/useImage'; import { ExplicitBadge } from '../common/ExplicitBadge'; +import { useTrackContextMenu } from '../common/ContextMenu'; import { fmtDuration } from '../../lib/itemHelpers'; import type { AlbumTrack } from '../../hooks/useAlbumBrowse'; import type { SonosItem } from '../../types/sonos'; @@ -27,12 +28,26 @@ export function AlbumTrackRow({ onClick, onDragStart, onAdd, }: Props) { const navigate = useNavigate(); + const { showTrackMenu } = useTrackContextMenu(); + + const trackPayload: PlaylistTrack = { + uri: track.id.objectId ?? '', + trackName: track.title, + artist: track.artists.join(', '), + albumName: track.albumName ?? undefined, + imageUrl: track.artUrl, + serviceId: track.id.serviceId ?? serviceId, + accountId: track.id.accountId ?? accountId, + addedBy: '', + addedAt: 0, + }; return (
showTrackMenu({ track: trackPayload }, e)} onDragStart={onDragStart} > {track.ordinal} diff --git a/renderer/src/components/artist/HeroTrackRow.tsx b/renderer/src/components/artist/HeroTrackRow.tsx index f023f6e..0b75efd 100644 --- a/renderer/src/components/artist/HeroTrackRow.tsx +++ b/renderer/src/components/artist/HeroTrackRow.tsx @@ -1,5 +1,6 @@ import { useImage } from '../../hooks/useImage'; import { ExplicitBadge } from '../common/ExplicitBadge'; +import { useTrackContextMenu } from '../common/ContextMenu'; import { fmtDuration } from '../../lib/itemHelpers'; import styles from './ArtistHero.module.css'; @@ -23,11 +24,26 @@ interface Props { export function HeroTrackRow({ track, index, isSelected, onClick, onDragStart, onAdd }: Props) { const art = useImage(track.artUrl); + const { showTrackMenu } = useTrackContextMenu(); + + const rid = track.raw.resource?.id; + const trackPayload: PlaylistTrack = { + uri: track.id?.objectId ?? rid?.objectId ?? '', + trackName: track.title, + artist: typeof track.raw.artist === 'string' ? track.raw.artist : (track.raw.artist?.name ?? ''), + imageUrl: track.artUrl, + serviceId: rid?.serviceId ?? '', + accountId: rid?.accountId ?? '', + addedBy: '', + addedAt: 0, + }; + return (
onClick(index, e)} + onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} onDragStart={e => onDragStart(index, e)} >
diff --git a/renderer/src/components/artist/TopSongRow.tsx b/renderer/src/components/artist/TopSongRow.tsx index ecd92c8..ac838bc 100644 --- a/renderer/src/components/artist/TopSongRow.tsx +++ b/renderer/src/components/artist/TopSongRow.tsx @@ -1,5 +1,6 @@ import { useImage } from '../../hooks/useImage'; import { ExplicitBadge } from '../common/ExplicitBadge'; +import { useTrackContextMenu } from '../common/ContextMenu'; import { fmtDuration } from '../../lib/itemHelpers'; import type { AlbumTrack } from '../../hooks/useAlbumBrowse'; import type { SonosItem } from '../../types/sonos'; @@ -18,12 +19,27 @@ interface Props { export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybackActive, onAdd, onClick, onDragStart }: Props) { const art = useImage(track.artUrl); + const { showTrackMenu } = useTrackContextMenu(); const subtitle = (track.raw as Record)?.['subtitle'] as string | undefined; + + const trackPayload: PlaylistTrack = { + uri: track.id.objectId ?? '', + trackName: track.title, + artist: track.artists.join(', '), + albumName: track.albumName ?? undefined, + imageUrl: track.artUrl, + serviceId: track.id.serviceId ?? '', + accountId: track.id.accountId ?? '', + addedBy: '', + addedAt: 0, + }; + return (
onClick(index, e)} + onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} onDragStart={e => onDragStart(index, e)} > {isCurrentTrack ? ( diff --git a/renderer/src/components/common/ContextMenu.tsx b/renderer/src/components/common/ContextMenu.tsx new file mode 100644 index 0000000..2f02f6a --- /dev/null +++ b/renderer/src/components/common/ContextMenu.tsx @@ -0,0 +1,354 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { useMyPlaylists } from '../../hooks/usePlaylists'; +import type { SonosItem } from '../../types/sonos'; +import styles from '../../styles/ContextMenu.module.css'; + +interface TrackMenuPayload { + track: PlaylistTrack; + sonosItem?: SonosItem; +} + +interface ContextMenuCtx { + showTrackMenu: (payload: TrackMenuPayload, e: React.MouseEvent) => void; +} + +const ContextMenuContext = createContext({ showTrackMenu: () => {} }); + +export function useTrackContextMenu() { + return useContext(ContextMenuContext); +} + +interface ProviderProps { + children: React.ReactNode; + displayName: string | null | undefined; + onAddToQueue: (item: SonosItem, position?: number) => void; +} + +interface MenuState { + open: boolean; + x: number; + y: number; + payload: TrackMenuPayload | null; + submenuOpen: boolean; + submenuAnchor: { x: number; y: number } | null; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function PlaylistSubmenu({ + anchor, + displayName, + track, + onClose, + onOpenCreate, +}: { + anchor: { x: number; y: number }; + displayName: string | null | undefined; + track: PlaylistTrack; + onClose: () => void; + onOpenCreate: () => void; +}) { + const { owned, joined, isLoading } = useMyPlaylists(displayName); + const queryClient = useQueryClient(); + const ref = useRef(null); + + const allPlaylists = [...owned, ...joined]; + + async function handleAdd(playlist: PlaylistMeta) { + try { + await window.sonos.addTrackToPlaylist(playlist.id, track); + queryClient.invalidateQueries({ queryKey: ['playlist', playlist.id] }); + } catch { + // silent + } + onClose(); + } + + // Position submenu to right; flip left if near edge + const menuW = 200; + const left = anchor.x + menuW > window.innerWidth - 8 ? anchor.x - menuW : anchor.x; + const top = clamp(anchor.y, 8, window.innerHeight - 8); + + return createPortal( +
e.stopPropagation()} + > + {isLoading &&
Loading…
} + {!isLoading && allPlaylists.length === 0 && ( +
No playlists yet
+ )} + {allPlaylists.map((pl) => ( +
handleAdd(pl)}> + + {pl.name} + {!pl.isPublic && 🔒} +
+ ))} + {allPlaylists.length > 0 &&
} +
{ onClose(); onOpenCreate(); }} + > + + + Create new playlist… +
+
, + document.body, + ); +} + +export function ContextMenuProvider({ children, displayName, onAddToQueue }: ProviderProps) { + const navigate = useNavigate(); + const [menuState, setMenuState] = useState({ + open: false, x: 0, y: 0, payload: null, submenuOpen: false, submenuAnchor: null, + }); + const [createOpen, setCreateOpen] = useState(false); + const [pendingTrack, setPendingTrack] = useState(null); + const menuRef = useRef(null); + + const close = useCallback(() => { + setMenuState((s) => ({ ...s, open: false, submenuOpen: false, submenuAnchor: null })); + }, []); + + const showTrackMenu = useCallback((payload: TrackMenuPayload, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const menuH = 120; + const menuW = 200; + const x = clamp(e.clientX, 8, window.innerWidth - menuW - 8); + const y = clamp(e.clientY, 8, window.innerHeight - menuH - 8); + setMenuState({ open: true, x, y, payload, submenuOpen: false, submenuAnchor: null }); + }, []); + + useEffect(() => { + if (!menuState.open) return; + function onMouseDown(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + close(); + } + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') close(); + } + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [menuState.open, close]); + + function handlePlayNext() { + if (menuState.payload?.sonosItem) onAddToQueue(menuState.payload.sonosItem, 0); + close(); + } + + function handleAddToQueue() { + if (menuState.payload?.sonosItem) onAddToQueue(menuState.payload.sonosItem, -1); + close(); + } + + function handleOpenPlaylistSubmenu(e: React.MouseEvent) { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setMenuState((s) => ({ + ...s, + submenuOpen: !s.submenuOpen, + submenuAnchor: { x: rect.right + 4, y: rect.top }, + })); + } + + function handleOpenProfile() { + if (menuState.payload?.track.addedBy) { + navigate(`/profile/${encodeURIComponent(menuState.payload.track.addedBy)}`); + } + close(); + } + + return ( + + {children} + {menuState.open && menuState.payload && createPortal( +
e.stopPropagation()} + > + {menuState.payload.sonosItem && ( + <> +
+ + Play next +
+
+ + + Add to queue +
+
+ + )} +
+ + Add to playlist + +
+ {menuState.payload.track.addedBy && ( + <> +
+
+ 👤 + View {menuState.payload.track.addedBy}'s profile +
+ + )} +
, + document.body, + )} + {menuState.open && menuState.submenuOpen && menuState.submenuAnchor && menuState.payload && ( + { + setPendingTrack(menuState.payload!.track); + setCreateOpen(true); + }} + /> + )} + {createOpen && ( + { setCreateOpen(false); setPendingTrack(null); }} + /> + )} + + ); +} + +// ── CreatePlaylistDialog ────────────────────────────────────────────────────── + +export function CreatePlaylistDialog({ + displayName, + pendingTrack, + onClose, +}: { + displayName: string | null | undefined; + pendingTrack: PlaylistTrack | null; + onClose: () => void; +}) { + const [name, setName] = useState(''); + const [isPublic, setIsPublic] = useState(false); + const [busy, setBusy] = useState(false); + const [errorMsg, setErrorMsg] = useState(''); + const queryClient = useQueryClient(); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + async function handleCreate() { + if (!name.trim() || !displayName) return; + setBusy(true); + setErrorMsg(''); + try { + const playlist = await window.sonos.createPlaylist(name.trim(), isPublic); + if (!playlist || typeof playlist !== 'object' || 'error' in playlist) { + setErrorMsg((playlist as { error?: string }).error ?? 'Failed to create playlist'); + setBusy(false); + return; + } + if (pendingTrack && (playlist as PlaylistDoc).id) { + await window.sonos.addTrackToPlaylist((playlist as PlaylistDoc).id, pendingTrack); + } + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + onClose(); + } catch (err) { + setErrorMsg(String(err)); + setBusy(false); + } + } + + return createPortal( +
+
e.stopPropagation()} + > +
New playlist
+ setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') onClose(); }} + placeholder="Playlist name" + style={{ + background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 8, + padding: '8px 12px', color: 'var(--text)', fontSize: 14, outline: 'none', + fontFamily: 'inherit', + }} + /> + + {errorMsg && ( +
+ {errorMsg} +
+ )} +
+ + +
+
+
, + document.body, + ); +} diff --git a/renderer/src/components/common/MediaRow.tsx b/renderer/src/components/common/MediaRow.tsx index 807fee9..1c74724 100644 --- a/renderer/src/components/common/MediaRow.tsx +++ b/renderer/src/components/common/MediaRow.tsx @@ -15,13 +15,14 @@ interface Props { onClick?: (e: React.MouseEvent) => void; onDoubleClick?: () => void; onHover?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; draggable?: boolean; onDragStart?: (e: React.DragEvent) => void; } export function MediaRow({ name, artUrl, explicit, isPlaying, isSelected, leading, subtitle, trailing, - onAdd, onClick, onDoubleClick, onHover, draggable, onDragStart, + onAdd, onClick, onDoubleClick, onHover, onContextMenu, draggable, onDragStart, }: Props) { const cachedArt = useImage(artUrl); const classes = [ @@ -39,6 +40,7 @@ export function MediaRow({ onClick={onClick} onDoubleClick={onDoubleClick} onMouseEnter={onHover} + onContextMenu={onContextMenu} onDragStart={onDragStart} > {leading !== undefined &&
{leading}
} diff --git a/renderer/src/components/queue/DraggableQueueRow.tsx b/renderer/src/components/queue/DraggableQueueRow.tsx index a850937..69cc2d4 100644 --- a/renderer/src/components/queue/DraggableQueueRow.tsx +++ b/renderer/src/components/queue/DraggableQueueRow.tsx @@ -4,6 +4,7 @@ import { useOpenItem } from '../../hooks/useOpenItem'; import { useQueueTrack } from '../../hooks/useQueueTrack'; import { getActiveProvider } from '../../providers'; import { ExplicitBadge } from '../common/ExplicitBadge'; +import { useTrackContextMenu } from '../common/ContextMenu'; import type { NormalizedQueueItem } from '../../types/provider'; import styles from '../../styles/QueueSidebar.module.css'; @@ -42,8 +43,21 @@ export function DraggableQueueRow({ const cachedArt = useImage(artUrl); const openItem = useOpenItem(); const navigate = useNavigate(); + const { showTrackMenu } = useTrackContextMenu(); const name = item.track.title; + const trackPayload: PlaylistTrack = { + uri: item.track.id, + trackName: item.track.title, + artist: item.track.artist, + albumName: item.track.albumName ?? undefined, + imageUrl: item.track.imageUrl, + serviceId: item.track.serviceId ?? '', + accountId: item.track.accountId ?? '', + addedBy: '', + addedAt: 0, + }; + return (
onRowClick(index, e)} onDoubleClick={() => getActiveProvider().skipToTrack(index + 1)} + onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} onDragStart={e => onDragStart(index, e)} onDragOver={e => onDragOver(index, e)} onDrop={onDrop} diff --git a/renderer/src/components/search/SearchResults.tsx b/renderer/src/components/search/SearchResults.tsx index 5ed07b8..5b56c0e 100644 --- a/renderer/src/components/search/SearchResults.tsx +++ b/renderer/src/components/search/SearchResults.tsx @@ -5,7 +5,8 @@ import { getName, browseSub, getItemArt, isAlbum, isArtist, isTrack } from '../. import { ArtistHero } from '../artist/ArtistHero'; import { ArtistCircle } from './ArtistCircle'; import { MediaRow } from '../common/MediaRow'; -import type { SonosItem, SonosArtist } from '../../types/sonos'; +import { useTrackContextMenu } from '../common/ContextMenu'; +import type { SonosItem, SonosArtist, SonosItemId } from '../../types/sonos'; import styles from './SearchResults.module.css'; function SearchAlbumCard({ album, onOpen, onAdd }: { album: SonosItem; onOpen: (item: SonosItem) => void; onAdd: () => void }) { @@ -32,9 +33,29 @@ export function SearchResults({ onAddToQueue: (item: SonosItem) => void; }) { const openItem = useOpenItem(); + const { showTrackMenu } = useTrackContextMenu(); const [selected, setSelected] = useState>(new Set()); const lastSelected = useRef(null); + function buildTrackPayload(item: SonosItem): PlaylistTrack { + const rid = (typeof item.id === 'object' ? item.id : item.resource?.id) as SonosItemId | undefined; + const artist = typeof item.artist === 'string' ? item.artist + : (item.artist as SonosArtist | undefined)?.name + ?? item.artists?.[0]?.name ?? ''; + const albumName = typeof item.album === 'string' ? item.album : item.album?.name; + return { + uri: rid?.objectId ?? item.objectId ?? '', + trackName: (item.title ?? item.name ?? '') as string, + artist, + albumName, + imageUrl: getItemArt(item), + serviceId: rid?.serviceId ?? item.serviceId ?? '', + accountId: rid?.accountId ?? item.accountId ?? '', + addedBy: '', + addedAt: 0, + }; + } + const topArtist = results.length > 0 && isArtist(results[0]) ? results[0] : null; const artists = results.filter(isArtist); const remainingArtists = topArtist ? artists.slice(1) : artists; @@ -148,6 +169,7 @@ export function SearchResults({ subtitle={subtitle} onAdd={() => onAddToQueue(item)} onClick={e => { e.stopPropagation(); handleTrackClick(i, e); }} + onContextMenu={e => showTrackMenu({ track: buildTrackPayload(item) }, e)} draggable onDragStart={e => handleDragStart(e, i)} /> diff --git a/renderer/src/hooks/usePlaylists.ts b/renderer/src/hooks/usePlaylists.ts new file mode 100644 index 0000000..f9e519a --- /dev/null +++ b/renderer/src/hooks/usePlaylists.ts @@ -0,0 +1,48 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +async function safeFetchPlaylists(filter: { owner?: string; member?: string }): Promise { + const result = await window.sonos.fetchPlaylists(filter); + return Array.isArray(result) ? result : []; +} + +export function useMyPlaylists(userName: string | null | undefined) { + const owned = useQuery({ + queryKey: ['playlists', 'owned', userName], + queryFn: () => safeFetchPlaylists({ owner: userName! }), + enabled: !!userName, + staleTime: 2 * 60_000, + }); + const joined = useQuery({ + queryKey: ['playlists', 'joined', userName], + queryFn: () => safeFetchPlaylists({ member: userName! }), + enabled: !!userName, + staleTime: 2 * 60_000, + }); + return { + owned: owned.data ?? [], + joined: joined.data ?? [], + isLoading: owned.isLoading || joined.isLoading, + refetch: () => { owned.refetch(); joined.refetch(); }, + }; +} + +export function usePlaylist(id: string | undefined) { + return useQuery({ + queryKey: ['playlist', id], + queryFn: async () => { + const result = await window.sonos.fetchPlaylist(id!); + if (!result || typeof result !== 'object' || 'error' in result) throw new Error('playlist not found'); + return result as PlaylistDoc; + }, + enabled: !!id, + staleTime: 60_000, + }); +} + +export function useInvalidatePlaylists() { + const queryClient = useQueryClient(); + return (userName: string) => { + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', userName] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'joined', userName] }); + }; +} diff --git a/renderer/src/styles/ContextMenu.module.css b/renderer/src/styles/ContextMenu.module.css new file mode 100644 index 0000000..0a04e92 --- /dev/null +++ b/renderer/src/styles/ContextMenu.module.css @@ -0,0 +1,90 @@ +.menu { + position: fixed; + z-index: 9999; + background: var(--bg-glass, rgba(22, 22, 24, 0.92)); + backdrop-filter: blur(24px) saturate(160%); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 4px; + min-width: 190px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.3); + animation: menuIn 0.1s ease-out; + user-select: none; +} + +@keyframes menuIn { + from { opacity: 0; transform: scale(0.97); } + to { opacity: 1; transform: scale(1); } +} + +.item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: var(--r-sm); + cursor: pointer; + font-size: 13px; + color: var(--text-2); + position: relative; + white-space: nowrap; + transition: background 0.08s, color 0.08s; +} +.item:hover { + background: var(--bg-2); + color: var(--text); +} + +.itemIcon { + font-size: 12px; + width: 16px; + text-align: center; + flex-shrink: 0; + color: var(--text-3); +} + +.itemArrow { + margin-left: auto; + font-size: 10px; + color: var(--text-3); + padding-left: 8px; +} + +.separator { + height: 1px; + background: var(--border); + margin: 4px 2px; +} + +/* Submenu */ +.submenuWrap { + position: relative; +} + +.submenu { + position: fixed; + z-index: 10000; + background: var(--bg-glass, rgba(22, 22, 24, 0.92)); + backdrop-filter: blur(24px) saturate(160%); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 4px; + min-width: 190px; + max-height: 280px; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55); + animation: menuIn 0.1s ease-out; +} + +.submenu::-webkit-scrollbar { width: 6px; } +.submenu::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.15); + border-radius: 3px; +} + +.emptyHint { + font-size: 12px; + color: var(--text-3); + padding: 6px 10px; + pointer-events: none; +} diff --git a/renderer/src/styles/HomePanel.module.css b/renderer/src/styles/HomePanel.module.css index 8e30d52..ce45692 100644 --- a/renderer/src/styles/HomePanel.module.css +++ b/renderer/src/styles/HomePanel.module.css @@ -15,6 +15,77 @@ margin-bottom: 18px; } +.sectionAction { + margin-left: auto; + background: none; + border: 1px solid var(--border); + border-radius: 12px; + padding: 4px 12px; + font-size: 12px; + font-family: inherit; + color: var(--text-3); + cursor: pointer; + transition: all 0.12s; +} +.sectionAction:hover { color: var(--text); border-color: var(--border-2); background: rgba(255,255,255,0.05); } + +.playlistRow { + display: flex; + gap: 12px; + overflow-x: auto; + padding-bottom: 8px; +} +.playlistRow::-webkit-scrollbar { height: 6px; } +.playlistRow::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } + +.playlistCard { + background: none; + border: none; + padding: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + cursor: pointer; + flex-shrink: 0; + width: 120px; + text-align: left; +} +.playlistCard:hover .playlistCardArt { opacity: 0.85; } + +.playlistCardArt { + width: 120px; + height: 120px; + border-radius: var(--r-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + font-weight: 700; + color: rgba(255, 255, 255, 0.6); + transition: opacity 0.15s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +.playlistCardName { + font-size: 12px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.playlistCardMeta { + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + .sectionTitle { font-size: 22px; font-weight: 700; diff --git a/renderer/src/styles/PlaylistPanel.module.css b/renderer/src/styles/PlaylistPanel.module.css new file mode 100644 index 0000000..5690694 --- /dev/null +++ b/renderer/src/styles/PlaylistPanel.module.css @@ -0,0 +1,348 @@ +.panel { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: calc(var(--player-h) + 24px); +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.header { + padding: calc(var(--nav-h) + 24px) 28px 24px; + background: linear-gradient(to bottom, rgba(255,255,255,0.04), transparent); +} + +.headerContent { + display: flex; + gap: 24px; + align-items: flex-end; +} + +.coverArt { + width: 180px; + height: 180px; + border-radius: var(--r-md); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 64px; + font-weight: 700; + color: rgba(255, 255, 255, 0.6); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + position: relative; + overflow: hidden; +} + +.coverArtOwner { cursor: pointer; } + +.coverArtImg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.coverArtOverlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + opacity: 0; + transition: opacity 0.15s; +} +.coverArtOwner:hover .coverArtOverlay { opacity: 1; } +.coverArtUploading { opacity: 1 !important; } + +.headerInfo { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 4px; +} + +.playlistLabel { + font-size: 11px; + font-weight: 600; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.playlistName { + font-size: 32px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.6px; + line-height: 1.1; + margin: 0; +} + +.metaRow { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 13px; + color: var(--text-3); +} + +.ownerLink { + background: none; + border: none; + padding: 0; + font-size: inherit; + font-family: inherit; + color: var(--text-2); + cursor: pointer; + font-weight: 500; +} +.ownerLink:hover { color: var(--text); text-decoration: underline; } + +.metaDot { color: var(--text-3); } + +.badge { + font-size: 12px; + color: var(--text-3); +} + +.headerActions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.playAllBtn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 20px; + padding: 8px 20px; + font-size: 13px; + font-weight: 600; + font-family: inherit; + color: var(--text); + cursor: pointer; + transition: background 0.15s; +} +.playAllBtn:hover { background: rgba(255, 255, 255, 0.18); } + +.joinBtn { + background: none; + border: 1px solid var(--border); + border-radius: 20px; + padding: 8px 20px; + font-size: 13px; + font-weight: 500; + font-family: inherit; + color: var(--text-2); + cursor: pointer; + transition: all 0.15s; +} +.joinBtn:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } +.joined { color: var(--text-3); } + +/* ── Track table ─────────────────────────────────────────────────────────── */ + +.tracks { + padding: 8px 28px 8px; + --playlist-cols: 32px 40px 3fr 1.4fr 56px; +} + +.tableHeader { + display: grid; + grid-template-columns: var(--playlist-cols); + align-items: center; + gap: 12px; + padding: 6px 8px 8px; + border-bottom: 1px solid var(--border); + margin-bottom: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--text-3); + text-transform: uppercase; +} + +.trackRow { + display: grid; + grid-template-columns: var(--playlist-cols); + align-items: center; + gap: 12px; + padding: 5px 8px; + border-radius: var(--r-sm); + transition: background 0.12s; + cursor: grab; +} +.trackRow:active { cursor: grabbing; } +.trackRow:hover { background: var(--bg-2); } +.trackRow:hover .addBtn { opacity: 1; } +.trackRow:hover .removeBtn { opacity: 1; } + +.ordinal { + text-align: right; + font-size: 13px; + color: var(--text-3); +} + +.trackArtWrap { + width: 40px; + height: 40px; + border-radius: 4px; + overflow: hidden; + background: var(--bg-1); + flex-shrink: 0; +} +.trackArt { width: 100%; height: 100%; object-fit: cover; display: block; } +.trackArtPh { width: 100%; height: 100%; background: var(--bg-2); } + +.trackMain { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.trackName { + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.trackArtist { + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trackAlbum { + font-size: 12px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.trackAddedBy { + font-size: 12px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.addedByLink { + background: none; + border: none; + padding: 0; + font-size: inherit; + font-family: inherit; + color: inherit; + cursor: pointer; +} +.addedByLink:hover { color: var(--text); text-decoration: underline; } + +.trackControls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +.addBtn { + width: 26px; + height: 26px; + background: none; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-3); + cursor: pointer; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + opacity: 0; + flex-shrink: 0; +} +.addBtn:hover { border-color: var(--border-2); color: var(--text-2); background: var(--bg-2); } + +.removeBtn { + background: none; + border: none; + color: var(--text-3); + font-size: 16px; + cursor: pointer; + padding: 0 2px; + line-height: 1; + opacity: 0; + transition: color 0.12s, opacity 0.12s; + flex-shrink: 0; +} +.removeBtn:hover { color: rgba(255, 80, 80, 0.9); } + +.emptyMsg { + font-size: 13px; + color: var(--text-3); + padding: 32px 8px; +} + +/* ── Members section ─────────────────────────────────────────────────────── */ + +.section { + padding: 0 28px; + margin-top: 32px; +} + +.sectionTitle { + font-size: 16px; + font-weight: 700; + color: var(--text-2); + margin-bottom: 12px; + letter-spacing: -0.2px; +} + +.memberList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.memberChip { + background: none; + border: 1px solid var(--border); + border-radius: 20px; + padding: 5px 14px; + font-size: 12px; + font-family: inherit; + color: var(--text-2); + cursor: pointer; + transition: all 0.12s; +} +.memberChip:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } + +/* ── Skeleton ────────────────────────────────────────────────────────────── */ + +@keyframes skelPulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.55; } +} + +.skelBlock { + border-radius: 4px; + background: var(--bg-1); + animation: skelPulse 1.8s ease-in-out infinite; +} diff --git a/renderer/src/styles/ProfilePanel.module.css b/renderer/src/styles/ProfilePanel.module.css index 3550535..607bfdc 100644 --- a/renderer/src/styles/ProfilePanel.module.css +++ b/renderer/src/styles/ProfilePanel.module.css @@ -89,14 +89,35 @@ margin-bottom: 44px; } +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + .sectionTitle { font-size: 22px; font-weight: 700; color: var(--text); letter-spacing: -0.4px; - margin-bottom: 4px; + margin-bottom: 0; } +.sectionAction { + background: none; + border: 1px solid var(--border); + border-radius: 20px; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; + font-family: inherit; + color: var(--text-2); + cursor: pointer; + transition: all 0.12s; +} +.sectionAction:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } + .subSectionTitle { font-size: 13px; font-weight: 600; @@ -223,3 +244,62 @@ background: var(--bg-1); animation: skelPulse 1.8s ease-in-out infinite; } + +/* ── Playlists ───────────────────────────────────────────────────────────── */ + +.playlistRow { + display: flex; + gap: 12px; + overflow-x: auto; + padding-bottom: 8px; +} +.playlistRow::-webkit-scrollbar { height: 6px; } +.playlistRow::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } + +.playlistCard { + background: none; + border: none; + padding: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + cursor: pointer; + flex-shrink: 0; + width: 120px; + text-align: left; +} +.playlistCard:hover .playlistCardArt { opacity: 0.85; } + +.playlistCardArt { + width: 120px; + height: 120px; + border-radius: var(--r-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + font-weight: 700; + color: rgba(255, 255, 255, 0.6); + transition: opacity 0.15s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +.playlistCardName { + font-size: 12px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.playlistCardMeta { + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} diff --git a/renderer/src/test/setup.ts b/renderer/src/test/setup.ts index 863568b..870767e 100644 --- a/renderer/src/test/setup.ts +++ b/renderer/src/test/setup.ts @@ -72,5 +72,11 @@ Object.defineProperty(window, 'sonos', { onWindowMaximized: vi.fn(() => () => {}), onUpdateDownloaded: vi.fn(noop), installUpdate: vi.fn(pending), + fetchPlaylists: vi.fn(() => Promise.resolve([])), + fetchPlaylist: vi.fn(pending), + createPlaylist: vi.fn(pending), + addTrackToPlaylist: vi.fn(pending), + joinPlaylist: vi.fn(pending), + uploadPlaylistImage: vi.fn(pending), }, }); diff --git a/renderer/src/types/globals.d.ts b/renderer/src/types/globals.d.ts index 3d2c587..ca45e66 100644 --- a/renderer/src/types/globals.d.ts +++ b/renderer/src/types/globals.d.ts @@ -229,6 +229,36 @@ interface RecentlyPlayedData { availableUsers?: string[]; } +interface PlaylistTrack { + uri: string; + trackName: string; + artist: string; + albumName?: string; + imageUrl?: string | null; + serviceId: string; + accountId: string; + addedBy: string; + addedAt: number; +} + +interface PlaylistMeta { + id: string; + name: string; + owner: string; + isPublic: boolean; + memberCount: number; + trackCount: number; + updatedAt: number; + imageUrl?: string | null; +} + +interface PlaylistDoc extends PlaylistMeta { + members: string[]; + tracks: PlaylistTrack[]; + createdAt: number; + imageUrl?: string | null; +} + interface SonosPreload { getVersion: () => Promise; isNewVersion: () => Promise; @@ -305,6 +335,12 @@ interface SonosPreload { onWindowMaximized: (cb: (maximized: boolean) => void) => Unsubscribe; onUpdateDownloaded: (cb: (version: string) => void) => Unsubscribe; installUpdate: () => Promise; + fetchPlaylists: (filter: { owner?: string; member?: string }) => Promise; + fetchPlaylist: (id: string) => Promise; + createPlaylist: (name: string, isPublic: boolean) => Promise; + addTrackToPlaylist: (playlistId: string, track: PlaylistTrack) => Promise; + joinPlaylist: (playlistId: string, action: 'join' | 'leave') => Promise; + uploadPlaylistImage: (playlistId: string, data: ArrayBuffer, mimeType: string) => Promise<{ imageUrl: string } | { error: string }>; } interface Window { diff --git a/server/azuredeploy.json b/server/azuredeploy.json index c21dabd..70a64d3 100644 --- a/server/azuredeploy.json +++ b/server/azuredeploy.json @@ -83,6 +83,15 @@ "publicAccess": "None" } }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "apiVersion": "2023-01-01", + "name": "[concat(variables('storageName'), '/default/playlist-images')]", + "dependsOn": ["[resourceId('Microsoft.Storage/storageAccounts', variables('storageName'))]"], + "properties": { + "publicAccess": "None" + } + }, { "type": "Microsoft.SignalRService/webPubSub", "apiVersion": "2023-02-01", @@ -168,6 +177,20 @@ } } }, + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2024-02-15-preview", + "name": "[concat(variables('cosmosName'), '/truetunes/playlists')]", + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosName'), 'truetunes')]" + ], + "properties": { + "resource": { + "id": "playlists", + "partitionKey": { "paths": ["/id"], "kind": "Hash" } + } + } + }, { "type": "Microsoft.Web/serverfarms", "apiVersion": "2023-01-01", diff --git a/server/src/functions/playlist-add-track.ts b/server/src/functions/playlist-add-track.ts new file mode 100644 index 0000000..0aa0f87 --- /dev/null +++ b/server/src/functions/playlist-add-track.ts @@ -0,0 +1,80 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +interface PlaylistTrack { + uri: string; + trackName: string; + artist: string; + albumName?: string; + imageUrl?: string | null; + serviceId: string; + accountId: string; + addedBy: string; + addedAt: number; +} + +export async function playlistAddTrackHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!connStr) { + return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + } + + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + let body: { track?: PlaylistTrack; userName?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { track, userName } = body; + if (!track || !userName) { + return { status: 400, jsonBody: { error: 'track and userName required' } }; + } + + try { + const client = new CosmosClient(connStr); + const container = client.database(dbName).container('playlists'); + + const { resource } = await container.item(id, id).read(); + if (!resource) { + return { status: 404, jsonBody: { error: 'Playlist not found' } }; + } + + if (!resource.members.includes(userName) && resource.owner !== userName) { + return { status: 403, jsonBody: { error: 'Not a member of this playlist' } }; + } + + const updatedTrack: PlaylistTrack = { ...track, addedBy: userName, addedAt: Date.now() }; + resource.tracks = [...(resource.tracks ?? []), updatedTrack]; + resource.updatedAt = Date.now(); + + await container.item(id, id).replace(resource); + + context.log(`[playlist-add-track] id=${id} userName=${userName} track=${track.trackName}`); + + return { + jsonBody: resource, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-add-track] failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('playlist-add-track', { + methods: ['POST'], + route: 'playlist/{id}/tracks', + authLevel: 'anonymous', + handler: playlistAddTrackHandler, +}); diff --git a/server/src/functions/playlist-create.ts b/server/src/functions/playlist-create.ts new file mode 100644 index 0000000..82d990c --- /dev/null +++ b/server/src/functions/playlist-create.ts @@ -0,0 +1,65 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +export async function playlistCreateHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!connStr) { + return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + } + + let body: { name?: string; isPublic?: boolean; owner?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { name, isPublic = false, owner } = body; + if (!name || !owner) { + return { status: 400, jsonBody: { error: 'name and owner required' } }; + } + + const id = crypto.randomUUID(); + const now = Date.now(); + + const doc = { + id, + name, + owner, + isPublic, + members: [owner], + tracks: [], + createdAt: now, + updatedAt: now, + }; + + try { + const client = new CosmosClient(connStr); + const container = client.database(dbName).container('playlists'); + + await container.items.create(doc); + + context.log(`[playlist-create] id=${id} owner=${owner} isPublic=${isPublic}`); + + return { + status: 201, + jsonBody: doc, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-create] create failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('playlist-create', { + methods: ['POST'], + route: 'playlists', + authLevel: 'anonymous', + handler: playlistCreateHandler, +}); diff --git a/server/src/functions/playlist-get.ts b/server/src/functions/playlist-get.ts new file mode 100644 index 0000000..deca693 --- /dev/null +++ b/server/src/functions/playlist-get.ts @@ -0,0 +1,47 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +export async function playlistGetHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!connStr) { + return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + } + + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + try { + const client = new CosmosClient(connStr); + const container = client.database(dbName).container('playlists'); + + const { resource } = await container.item(id, id).read(); + + if (!resource) { + return { status: 404, jsonBody: { error: 'Playlist not found' } }; + } + + context.log(`[playlist-get] id=${id} tracks=${resource.tracks?.length ?? 0}`); + + return { + jsonBody: resource, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-get] read failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('playlist-get', { + methods: ['GET'], + route: 'playlist/{id}', + authLevel: 'anonymous', + handler: playlistGetHandler, +}); diff --git a/server/src/functions/playlist-join.ts b/server/src/functions/playlist-join.ts new file mode 100644 index 0000000..cdb75b7 --- /dev/null +++ b/server/src/functions/playlist-join.ts @@ -0,0 +1,84 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +export async function playlistJoinHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!connStr) { + return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + } + + const id = request.params['id']; + if (!id) { + return { status: 400, jsonBody: { error: 'id param required' } }; + } + + let body: { userName?: string; action?: 'join' | 'leave' }; + try { + body = (await request.json()) as typeof body; + } catch { + return { status: 400, jsonBody: { error: 'Invalid JSON body' } }; + } + + const { userName, action = 'join' } = body; + if (!userName) { + return { status: 400, jsonBody: { error: 'userName required' } }; + } + + try { + const client = new CosmosClient(connStr); + const container = client.database(dbName).container('playlists'); + + const { resource } = await container.item(id, id).read(); + if (!resource) { + return { status: 404, jsonBody: { error: 'Playlist not found' } }; + } + + if (action === 'join') { + if (!resource.isPublic) { + return { status: 403, jsonBody: { error: 'Playlist is private' } }; + } + if (!resource.members.includes(userName)) { + resource.members = [...resource.members, userName]; + resource.updatedAt = Date.now(); + await container.item(id, id).replace(resource); + } + } else { + if (resource.owner === userName) { + return { status: 400, jsonBody: { error: 'Owner cannot leave their own playlist' } }; + } + resource.members = resource.members.filter((m: string) => m !== userName); + resource.updatedAt = Date.now(); + await container.item(id, id).replace(resource); + } + + context.log(`[playlist-join] id=${id} userName=${userName} action=${action}`); + + return { + jsonBody: { + id: resource.id, + name: resource.name, + owner: resource.owner, + isPublic: resource.isPublic, + memberCount: resource.members.length, + trackCount: (resource.tracks ?? []).length, + updatedAt: resource.updatedAt, + }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-join] failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('playlist-join', { + methods: ['POST'], + route: 'playlist/{id}/members', + authLevel: 'anonymous', + handler: playlistJoinHandler, +}); diff --git a/server/src/functions/playlist-list.ts b/server/src/functions/playlist-list.ts new file mode 100644 index 0000000..1c8d3f3 --- /dev/null +++ b/server/src/functions/playlist-list.ts @@ -0,0 +1,82 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { CosmosClient } from '@azure/cosmos'; + +interface PlaylistDoc { + id: string; + name: string; + owner: string; + isPublic: boolean; + members: string[]; + tracks: unknown[]; + imageUrl?: string | null; + createdAt: number; + updatedAt: number; +} + +export async function playlistListHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const connStr = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!connStr) { + return { status: 500, jsonBody: { error: 'Cosmos not configured' } }; + } + + const owner = request.query.get('owner'); + const member = request.query.get('member'); + + if (!owner && !member) { + return { status: 400, jsonBody: { error: 'owner or member query param required' } }; + } + + try { + const client = new CosmosClient(connStr); + const container = client.database(dbName).container('playlists'); + + let query: { query: string; parameters: { name: string; value: string }[] }; + + if (owner) { + query = { + query: 'SELECT c.id, c.name, c.owner, c.isPublic, c.members, c.imageUrl, c.createdAt, c.updatedAt, ARRAY_LENGTH(c.tracks) AS trackCount FROM c WHERE c.owner = @owner ORDER BY c.updatedAt DESC', + parameters: [{ name: '@owner', value: owner }], + }; + } else { + query = { + query: 'SELECT c.id, c.name, c.owner, c.isPublic, c.members, c.imageUrl, c.createdAt, c.updatedAt, ARRAY_LENGTH(c.tracks) AS trackCount FROM c WHERE c.isPublic = true AND ARRAY_CONTAINS(c.members, @member) AND c.owner != @member ORDER BY c.updatedAt DESC', + parameters: [{ name: '@member', value: member! }], + }; + } + + const { resources } = await container.items.query(query).fetchAll(); + + const result = resources.map((r) => ({ + id: r.id, + name: r.name, + owner: r.owner, + isPublic: r.isPublic, + memberCount: (r.members ?? []).length, + trackCount: r.trackCount ?? 0, + updatedAt: r.updatedAt, + imageUrl: r.imageUrl ?? null, + })); + + context.log(`[playlist-list] owner=${owner ?? ''} member=${member ?? ''} count=${result.length}`); + + return { + jsonBody: result, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-list] query failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('playlist-list', { + methods: ['GET'], + route: 'playlists', + authLevel: 'anonymous', + handler: playlistListHandler, +}); diff --git a/server/src/functions/playlist-upload-image.ts b/server/src/functions/playlist-upload-image.ts new file mode 100644 index 0000000..37e3ce7 --- /dev/null +++ b/server/src/functions/playlist-upload-image.ts @@ -0,0 +1,109 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { + BlobServiceClient, + StorageSharedKeyCredential, + BlobSASPermissions, + generateBlobSASQueryParameters, +} from '@azure/storage-blob'; +import { CosmosClient } from '@azure/cosmos'; + +const ALLOWED_TYPES: Record = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +function parseConnStr(s: string): { accountName: string; accountKey: string } { + const parts = Object.fromEntries( + s.split(';').filter(Boolean).map(p => { + const idx = p.indexOf('='); + return [p.slice(0, idx), p.slice(idx + 1)] as [string, string]; + }) + ); + return { accountName: parts['AccountName'] ?? '', accountKey: parts['AccountKey'] ?? '' }; +} + +export async function playlistUploadImageHandler( + request: HttpRequest, + context: InvocationContext, +): Promise { + const id = request.params['id']; + if (!id) return { status: 400, jsonBody: { error: 'id required' } }; + + const storageConn = process.env['STORAGE_CONNECTION_STRING']; + const cosmosConn = process.env['COSMOS_CONNECTION_STRING']; + const dbName = process.env['COSMOS_DATABASE'] ?? 'truetunes'; + + if (!storageConn || !cosmosConn) { + return { status: 500, jsonBody: { error: 'Storage or Cosmos not configured' } }; + } + + const mimeType = request.headers.get('content-type')?.split(';')[0]?.trim() ?? ''; + const ext = ALLOWED_TYPES[mimeType]; + if (!ext) { + return { status: 400, jsonBody: { error: `Unsupported image type: ${mimeType}` } }; + } + + const body = await request.arrayBuffer(); + if (!body || body.byteLength === 0) return { status: 400, jsonBody: { error: 'Empty body' } }; + if (body.byteLength > 5 * 1024 * 1024) return { status: 413, jsonBody: { error: 'Image must be under 5 MB' } }; + + try { + const { accountName, accountKey } = parseConnStr(storageConn); + const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey); + const blobServiceClient = new BlobServiceClient( + `https://${accountName}.blob.core.windows.net`, + sharedKeyCredential, + ); + + const containerClient = blobServiceClient.getContainerClient('playlist-images'); + await containerClient.createIfNotExists(); + + const blobName = `${id}.${ext}`; + const blockBlob = containerClient.getBlockBlobClient(blobName); + await blockBlob.upload(body, body.byteLength, { + blobHTTPHeaders: { blobContentType: mimeType }, + }); + + // SAS URL valid for 20 years — stored in Cosmos as the imageUrl + const expiresOn = new Date(); + expiresOn.setFullYear(expiresOn.getFullYear() + 20); + const sas = generateBlobSASQueryParameters( + { + containerName: 'playlist-images', + blobName, + permissions: BlobSASPermissions.parse('r'), + startsOn: new Date(), + expiresOn, + }, + sharedKeyCredential, + ); + const imageUrl = `${blockBlob.url}?${sas}`; + + // Patch Cosmos doc + const cosmos = new CosmosClient(cosmosConn); + await cosmos.database(dbName).container('playlists').item(id, id).patch([ + { op: 'set', path: '/imageUrl', value: imageUrl }, + { op: 'set', path: '/updatedAt', value: Date.now() }, + ]); + + context.log(`[playlist-upload-image] id=${id} ext=${ext} size=${body.byteLength}`); + + return { + jsonBody: { imageUrl }, + headers: { 'Access-Control-Allow-Origin': '*' }, + }; + } catch (err) { + context.error('[playlist-upload-image] failed:', err); + return { status: 500, jsonBody: { error: String(err) } }; + } +} + +app.http('playlist-upload-image', { + methods: ['POST'], + route: 'playlist/{id}/image', + authLevel: 'anonymous', + handler: playlistUploadImageHandler, +}); diff --git a/src/main.ts b/src/main.ts index be9829b..df45566 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1236,6 +1236,79 @@ ipcMain.handle('telemetry:event', (_: IpcMainInvokeEvent, name: string, props?: telemetry.event(name, props); }); +ipcMain.handle('playlist:list', async (_: IpcMainInvokeEvent, filter: { owner?: string; member?: string }) => { + try { + const params = new URLSearchParams(); + if (filter.owner) params.set('owner', filter.owner); + if (filter.member) params.set('member', filter.member); + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/playlists?${params}`); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('playlist:get', async (_: IpcMainInvokeEvent, id: string) => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(id)}`); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('playlist:create', async (_: IpcMainInvokeEvent, name: string, isPublic: boolean) => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/playlists`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, isPublic, owner: config.displayName }), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('playlist:addTrack', async (_: IpcMainInvokeEvent, playlistId: string, track: unknown) => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/tracks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track, userName: config.displayName }), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('playlist:join', async (_: IpcMainInvokeEvent, playlistId: string, action: 'join' | 'leave') => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: config.displayName, action }), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + +ipcMain.handle('playlist:uploadImage', async (_: IpcMainInvokeEvent, playlistId: string, data: ArrayBuffer, mimeType: string) => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/image`, { + method: 'POST', + headers: { 'Content-Type': mimeType }, + body: Buffer.from(data), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } +}); + ipcMain.handle( 'pubsub:publishQueued', async (_: IpcMainInvokeEvent, item: { eventType: 'track' | 'album'; uri: string; trackName: string; artist: string; artistId?: string; album?: string; albumId?: string; imageUrl?: string }) => { diff --git a/src/preload.ts b/src/preload.ts index 6ff6955..e0b169e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -61,6 +61,12 @@ export interface SonosAPI { geniusDescription: (trackName: string, artistName: string) => Promise; geniusArtist: (artistName: string, trackHint?: string) => Promise; trackEvent: (name: string, properties?: Record) => Promise; + fetchPlaylists: (filter: { owner?: string; member?: string }) => Promise; + fetchPlaylist: (id: string) => Promise; + createPlaylist: (name: string, isPublic: boolean) => Promise; + addTrackToPlaylist: (playlistId: string, track: unknown) => Promise; + joinPlaylist: (playlistId: string, action: 'join' | 'leave') => Promise; + uploadPlaylistImage: (playlistId: string, data: ArrayBuffer, mimeType: string) => Promise; minimizeWindow: () => Promise; maximizeWindow: () => Promise; closeWindow: () => Promise; @@ -197,4 +203,10 @@ contextBridge.exposeInMainWorld('sonos', { return () => ipcRenderer.removeListener('update:downloaded', listener); }, installUpdate: () => ipcRenderer.invoke('update:install'), + fetchPlaylists: (filter) => ipcRenderer.invoke('playlist:list', filter), + fetchPlaylist: (id) => ipcRenderer.invoke('playlist:get', id), + createPlaylist: (name, isPublic) => ipcRenderer.invoke('playlist:create', name, isPublic), + addTrackToPlaylist: (playlistId, track) => ipcRenderer.invoke('playlist:addTrack', playlistId, track), + joinPlaylist: (playlistId, action) => ipcRenderer.invoke('playlist:join', playlistId, action), + uploadPlaylistImage: (playlistId, data, mimeType) => ipcRenderer.invoke('playlist:uploadImage', playlistId, data, mimeType), } satisfies SonosAPI); From ce2440c25c8441a424d24300b73c2bc934e24460 Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 8 May 2026 19:04:55 +0100 Subject: [PATCH 07/10] refactor: extract shared utilities and redesign profile nav UX - Extract getPlaylistColor, createDragGhost, PlaylistCard into shared modules - Move @keyframes skelPulse to global.css (was duplicated in 3 modules) - Profile chip in TopNav navigates directly to profile page; sign-in shows name-entry popover - Sign out button moved to profile page, inline with play count (X plays | Sign out) - Playlists section moved above Recently Played on profile page with + New button - Playlist cards now show cover art via imageUrl from list endpoint Co-Authored-By: Claude Sonnet 4.6 --- renderer/src/App.tsx | 7 +- renderer/src/components/HomePanel.tsx | 46 +----- renderer/src/components/PlaylistPanel.tsx | 11 +- renderer/src/components/ProfilePanel.tsx | 49 ++----- renderer/src/components/TopNav.tsx | 128 +++++++++-------- renderer/src/components/album/AlbumPanel.tsx | 13 +- renderer/src/components/artist/ArtistHero.tsx | 13 +- .../components/common/PlaylistCard.module.css | 47 ++++++ .../src/components/common/PlaylistCard.tsx | 32 +++++ .../src/components/queue/QueueSidebar.tsx | 15 +- .../src/components/search/SearchResults.tsx | 13 +- renderer/src/lib/dragHelpers.ts | 13 ++ renderer/src/lib/playlistColor.ts | 9 ++ renderer/src/styles/HomePanel.module.css | 53 ------- renderer/src/styles/PlaylistPanel.module.css | 5 - renderer/src/styles/ProfilePanel.module.css | 72 +++------- renderer/src/styles/TopNav.module.css | 134 +++++++++++++++++- renderer/src/styles/global.css | 5 + 18 files changed, 350 insertions(+), 315 deletions(-) create mode 100644 renderer/src/components/common/PlaylistCard.module.css create mode 100644 renderer/src/components/common/PlaylistCard.tsx create mode 100644 renderer/src/lib/dragHelpers.ts create mode 100644 renderer/src/lib/playlistColor.ts diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index 8ce5205..2663085 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -431,8 +431,9 @@ useEffect(() => { onResync={() => window.sonos.resync()} displayName={displayName} onSaveName={(name) => { - window.sonos.setDisplayName(name).catch(() => {}); - setDisplayName(name); + const stored = name.trim(); + window.sonos.setDisplayName(stored).catch(() => {}); + setDisplayName(stored || null); }} onChangelogOpen={() => setChangelogOpen(true)} /> @@ -468,7 +469,7 @@ useEffect(() => { } /> } /> } /> - } /> + { window.sonos.setDisplayName('').catch(() => {}); setDisplayName(null); }} />} /> } /> function handleDragStart(e: React.DragEvent) { e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('application/sonos-item-list', JSON.stringify([item])); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', - }); - ghost.textContent = getName(item); - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(getName(item), e.dataTransfer); } return ( @@ -138,38 +130,6 @@ export async function fetchYtmSections(): Promise { }; } -function getPlaylistColor(name: string): string { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - hash |= 0; - } - const hue = Math.abs(hash) % 360; - return `linear-gradient(135deg, hsl(${hue},45%,28%), hsl(${(hue + 50) % 360},50%,20%))`; -} - -function PlaylistCard({ pl, displayName, onClick }: { pl: PlaylistMeta; displayName?: string | null; onClick: () => void }) { - const art = useImage(pl.imageUrl ?? null); - return ( - - ); -} - export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName }: Props) { const queryClient = useQueryClient(); const location = useLocation(); diff --git a/renderer/src/components/PlaylistPanel.tsx b/renderer/src/components/PlaylistPanel.tsx index eb43d7d..8ba498d 100644 --- a/renderer/src/components/PlaylistPanel.tsx +++ b/renderer/src/components/PlaylistPanel.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { usePlaylist } from '../hooks/usePlaylists'; import { useImage } from '../hooks/useImage'; import { useTrackContextMenu } from './common/ContextMenu'; +import { getPlaylistColor } from '../lib/playlistColor'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/PlaylistPanel.module.css'; @@ -12,16 +13,6 @@ interface Props { onAddToQueue: (item: SonosItem, position?: number) => void; } -function getPlaylistColor(name: string): string { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - hash |= 0; - } - const hue = Math.abs(hash) % 360; - return `linear-gradient(135deg, hsl(${hue},45%,30%), hsl(${(hue + 50) % 360},50%,22%))`; -} - function trackToSonosItem(t: PlaylistTrack): SonosItem { return { type: 'TRACK', diff --git a/renderer/src/components/ProfilePanel.tsx b/renderer/src/components/ProfilePanel.tsx index 871df96..0d646f5 100644 --- a/renderer/src/components/ProfilePanel.tsx +++ b/renderer/src/components/ProfilePanel.tsx @@ -6,45 +6,13 @@ import { useGameRankings } from '../hooks/useDailyGame'; import { useMyPlaylists } from '../hooks/usePlaylists'; import { getGameRankIcon } from '../lib/gameRankAssets'; import { useOpenItem } from '../hooks/useOpenItem'; -import { useImage } from '../hooks/useImage'; import { CardRow } from './CardRow'; import { MediaRow } from './common/MediaRow'; import { CreatePlaylistDialog } from './common/ContextMenu'; +import { PlaylistCard } from './common/PlaylistCard'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/ProfilePanel.module.css'; -function getPlaylistColor(name: string): string { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - hash |= 0; - } - const hue = Math.abs(hash) % 360; - return `linear-gradient(135deg, hsl(${hue},45%,28%), hsl(${(hue + 50) % 360},50%,20%))`; -} - -function PlaylistCard({ pl, onClick }: { pl: PlaylistMeta; onClick: () => void }) { - const art = useImage(pl.imageUrl ?? null); - return ( - - ); -} - function getAvatarStyle(name: string): React.CSSProperties { let hash = 0; for (let i = 0; i < name.length; i++) { @@ -60,9 +28,10 @@ function getAvatarStyle(name: string): React.CSSProperties { interface Props { onAddToQueue: (item: SonosItem) => void; displayName?: string | null; + onSignOut?: () => void; } -export function ProfilePanel({ onAddToQueue, displayName }: Props) { +export function ProfilePanel({ onAddToQueue, displayName, onSignOut }: Props) { const { userName } = useParams<{ userName: string }>(); const navigate = useNavigate(); const openItem = useOpenItem(); @@ -97,9 +66,17 @@ export function ProfilePanel({ onAddToQueue, displayName }: Props) {

{userName}

- {totalEvents > 0 && ( + {(totalEvents > 0 || isOwnProfile) && ( - {totalEvents.toLocaleString()} total plays + {totalEvents > 0 && ( + <>{totalEvents.toLocaleString()} plays + )} + {isOwnProfile && onSignOut && ( + <> + {totalEvents > 0 && |} + + + )} )}
diff --git a/renderer/src/components/TopNav.tsx b/renderer/src/components/TopNav.tsx index 4f5fa26..d13a965 100644 --- a/renderer/src/components/TopNav.tsx +++ b/renderer/src/components/TopNav.tsx @@ -7,8 +7,6 @@ import { Trophy, Search, X, - User, - Contact, RefreshCw, Gamepad2, Lightbulb, @@ -43,11 +41,11 @@ export function TopNav({ const location = useLocation(); const [searchParams] = useSearchParams(); const [searchText, setSearchText] = useState(searchParams.get('q') ?? ''); - const [nameOpen, setNameOpen] = useState(false); + const [profileOpen, setProfileOpen] = useState(false); const [nameValue, setNameValue] = useState(''); const [groupOpen, setGroupOpen] = useState(false); const [appVersion, setAppVersion] = useState(null); - const popoverRef = useRef(null); + const profileRef = useRef(null); const groupRef = useRef(null); // React Router sets window.history.state = { idx, key } on every navigation @@ -63,19 +61,19 @@ export function TopNav({ }, []); useEffect(() => { - if (nameOpen) setNameValue(displayName ?? ''); - }, [nameOpen, displayName]); + if (profileOpen) setNameValue(''); + }, [profileOpen]); useEffect(() => { - if (!nameOpen) return; + if (!profileOpen) return; function onDown(e: MouseEvent) { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { - setNameOpen(false); + if (profileRef.current && !profileRef.current.contains(e.target as Node)) { + setProfileOpen(false); } } document.addEventListener('mousedown', onDown); return () => document.removeEventListener('mousedown', onDown); - }, [nameOpen]); + }, [profileOpen]); useEffect(() => { if (!groupOpen) return; @@ -92,10 +90,15 @@ export function TopNav({ const trimmed = nameValue.trim(); if (trimmed) { onSaveName(trimmed); - setNameOpen(false); + setProfileOpen(false); } } + function signOut() { + onSaveName(''); + setProfileOpen(false); + } + useEffect(() => { if (location.pathname === '/search') { setSearchText(searchParams.get('q') ?? ''); @@ -168,6 +171,60 @@ export function TopNav({
+ {/* Profile chip — left of search */} + {displayName !== undefined && ( +
+ {displayName ? ( + + ) : ( + <> + + {profileOpen && ( +
+
Enter your name to get started
+ setNameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitName(); + if (e.key === 'Escape') setProfileOpen(false); + }} + maxLength={32} + spellCheck={false} + autoFocus + /> + +
+ {appVersion &&
v{appVersion}
} +
+
+ )} + + )} +
+ )} +
)} - {displayName !== undefined && ( -
- - {nameOpen && ( -
-
Display name
- setNameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') submitName(); - if (e.key === 'Escape') setNameOpen(false); - }} - maxLength={32} - spellCheck={false} - autoFocus - /> - -
- {appVersion &&
v{appVersion}
} -
-
- )} -
- )} - - {displayName && ( - - )} - diff --git a/renderer/src/components/album/AlbumPanel.tsx b/renderer/src/components/album/AlbumPanel.tsx index 3a5b21f..f7725cc 100644 --- a/renderer/src/components/album/AlbumPanel.tsx +++ b/renderer/src/components/album/AlbumPanel.tsx @@ -7,6 +7,7 @@ import { usePlaylistBrowse } from '../../hooks/usePlaylistBrowse'; import { useDominantColor } from '../../hooks/useDominantColor'; import { artistQueryOptions } from '../../hooks/useArtistBrowse'; import { resolveAlbumParams, isPlaylist, isProgram, getItemArt } from '../../lib/itemHelpers'; +import { createDragGhost } from '../../lib/dragHelpers'; import { AlbumTrackRow } from './AlbumTrackRow'; import type { SonosItem, SonosItemId } from '../../types/sonos'; import styles from '../../styles/AlbumPanel.module.css'; @@ -84,17 +85,7 @@ export function AlbumPanel({ onAddToQueue }: Props) { if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(idx => data.tracks[idx].raw))); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', - }); - ghost.textContent = toMove.length > 1 ? `${toMove.length} tracks` : data.tracks[index].title; - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : data.tracks[index].title, e.dataTransfer); } const totalSecs = data?.tracks.reduce((s, t) => s + t.durationSeconds, 0) ?? 0; diff --git a/renderer/src/components/artist/ArtistHero.tsx b/renderer/src/components/artist/ArtistHero.tsx index 7bc36af..4b6e8b0 100644 --- a/renderer/src/components/artist/ArtistHero.tsx +++ b/renderer/src/components/artist/ArtistHero.tsx @@ -4,6 +4,7 @@ import { useImage } from '../../hooks/useImage'; import { useDominantColor } from '../../hooks/useDominantColor'; import { artistQueryOptions } from '../../hooks/useArtistBrowse'; import { resolveArtistParams, getItemArt, getName } from '../../lib/itemHelpers'; +import { createDragGhost } from '../../lib/dragHelpers'; import { HeroTrackRow } from './HeroTrackRow'; import type { SonosItem } from '../../types/sonos'; import styles from './ArtistHero.module.css'; @@ -64,17 +65,7 @@ export function ArtistHero({ if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(i => tracks[i].raw))); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', - }); - ghost.textContent = toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[index].raw); - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[index].raw), e.dataTransfer); } return ( diff --git a/renderer/src/components/common/PlaylistCard.module.css b/renderer/src/components/common/PlaylistCard.module.css new file mode 100644 index 0000000..e2a5d05 --- /dev/null +++ b/renderer/src/components/common/PlaylistCard.module.css @@ -0,0 +1,47 @@ +.playlistCard { + background: none; + border: none; + padding: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + cursor: pointer; + flex-shrink: 0; + width: 120px; + text-align: left; +} +.playlistCard:hover .playlistCardArt { opacity: 0.85; } + +.playlistCardArt { + width: 120px; + height: 120px; + border-radius: var(--r-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + font-weight: 700; + color: rgba(255, 255, 255, 0.6); + transition: opacity 0.15s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +.playlistCardName { + font-size: 12px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.playlistCardMeta { + font-size: 11px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} diff --git a/renderer/src/components/common/PlaylistCard.tsx b/renderer/src/components/common/PlaylistCard.tsx new file mode 100644 index 0000000..660c7ba --- /dev/null +++ b/renderer/src/components/common/PlaylistCard.tsx @@ -0,0 +1,32 @@ +import { useImage } from '../../hooks/useImage'; +import { getPlaylistColor } from '../../lib/playlistColor'; +import styles from './PlaylistCard.module.css'; + +interface Props { + pl: PlaylistMeta; + displayName?: string | null; + onClick: () => void; +} + +export function PlaylistCard({ pl, displayName, onClick }: Props) { + const art = useImage(pl.imageUrl ?? null); + return ( + + ); +} diff --git a/renderer/src/components/queue/QueueSidebar.tsx b/renderer/src/components/queue/QueueSidebar.tsx index 1973d73..fa481a2 100644 --- a/renderer/src/components/queue/QueueSidebar.tsx +++ b/renderer/src/components/queue/QueueSidebar.tsx @@ -2,6 +2,7 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState, Fragment, fo import { useQueries } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; import { applyReorderLocally } from '../../lib/queueHelpers'; +import { createDragGhost } from '../../lib/dragHelpers'; import { getActiveProvider } from '../../providers'; import { useAttribution } from '../../hooks/useAttribution'; import { trackQueryOptions } from '../../hooks/useTrackDetails'; @@ -207,19 +208,7 @@ export const QueueSidebar = forwardRef(function Queue draggingSet.current = toMove; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('application/queue-indices', JSON.stringify([...toMove])); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', - whiteSpace: 'nowrap', zIndex: '9999', - }); - const label = toMove.size > 1 ? `${toMove.size} tracks` : (items[index]?.track.title ?? ''); - ghost.textContent = label; - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(toMove.size > 1 ? `${toMove.size} tracks` : (items[index]?.track.title ?? ''), e.dataTransfer); } function handleDragOver(index: number, e: React.DragEvent) { diff --git a/renderer/src/components/search/SearchResults.tsx b/renderer/src/components/search/SearchResults.tsx index 5b56c0e..41dd0f7 100644 --- a/renderer/src/components/search/SearchResults.tsx +++ b/renderer/src/components/search/SearchResults.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'; import { useImage } from '../../hooks/useImage'; import { useOpenItem } from '../../hooks/useOpenItem'; import { getName, browseSub, getItemArt, isAlbum, isArtist, isTrack } from '../../lib/itemHelpers'; +import { createDragGhost } from '../../lib/dragHelpers'; import { ArtistHero } from '../artist/ArtistHero'; import { ArtistCircle } from './ArtistCircle'; import { MediaRow } from '../common/MediaRow'; @@ -82,17 +83,7 @@ export function SearchResults({ if (!selected.has(i)) { setSelected(new Set([i])); lastSelected.current = i; } e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(idx => tracks[idx]))); - const ghost = document.createElement('div'); - Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', - }); - ghost.textContent = toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[i]); - document.body.appendChild(ghost); - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); - setTimeout(() => ghost.remove(), 0); + createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[i]), e.dataTransfer); } if (results.length === 0) return
No results.
; diff --git a/renderer/src/lib/dragHelpers.ts b/renderer/src/lib/dragHelpers.ts new file mode 100644 index 0000000..e13238c --- /dev/null +++ b/renderer/src/lib/dragHelpers.ts @@ -0,0 +1,13 @@ +export function createDragGhost(label: string, dataTransfer: DataTransfer): void { + const ghost = document.createElement('div'); + Object.assign(ghost.style, { + position: 'fixed', top: '-100px', left: '0', + background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', + color: '#fff', padding: '5px 12px', borderRadius: '6px', + fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', + }); + ghost.textContent = label; + document.body.appendChild(ghost); + dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, 20); + setTimeout(() => ghost.remove(), 0); +} diff --git a/renderer/src/lib/playlistColor.ts b/renderer/src/lib/playlistColor.ts new file mode 100644 index 0000000..4d9069a --- /dev/null +++ b/renderer/src/lib/playlistColor.ts @@ -0,0 +1,9 @@ +export function getPlaylistColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return `linear-gradient(135deg, hsl(${hue},45%,28%), hsl(${(hue + 50) % 360},50%,20%))`; +} diff --git a/renderer/src/styles/HomePanel.module.css b/renderer/src/styles/HomePanel.module.css index ce45692..5ee8679 100644 --- a/renderer/src/styles/HomePanel.module.css +++ b/renderer/src/styles/HomePanel.module.css @@ -38,54 +38,6 @@ .playlistRow::-webkit-scrollbar { height: 6px; } .playlistRow::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } -.playlistCard { - background: none; - border: none; - padding: 0; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; - cursor: pointer; - flex-shrink: 0; - width: 120px; - text-align: left; -} -.playlistCard:hover .playlistCardArt { opacity: 0.85; } - -.playlistCardArt { - width: 120px; - height: 120px; - border-radius: var(--r-md); - display: flex; - align-items: center; - justify-content: center; - font-size: 40px; - font-weight: 700; - color: rgba(255, 255, 255, 0.6); - transition: opacity 0.15s; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); -} - -.playlistCardName { - font-size: 12px; - font-weight: 600; - color: var(--text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 120px; -} - -.playlistCardMeta { - font-size: 11px; - color: var(--text-3); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 120px; -} - .sectionTitle { font-size: 22px; font-weight: 700; @@ -123,11 +75,6 @@ /* ── Skeletons ───────────────────────────────────────────────────────────── */ -@keyframes skelPulse { - 0%, 100% { opacity: 0.3; } - 50% { opacity: 0.55; } -} - .skelTitle { height: 18px; width: 56px; diff --git a/renderer/src/styles/PlaylistPanel.module.css b/renderer/src/styles/PlaylistPanel.module.css index 5690694..b5d306e 100644 --- a/renderer/src/styles/PlaylistPanel.module.css +++ b/renderer/src/styles/PlaylistPanel.module.css @@ -336,11 +336,6 @@ /* ── Skeleton ────────────────────────────────────────────────────────────── */ -@keyframes skelPulse { - 0%, 100% { opacity: 0.3; } - 50% { opacity: 0.55; } -} - .skelBlock { border-radius: 4px; background: var(--bg-1); diff --git a/renderer/src/styles/ProfilePanel.module.css b/renderer/src/styles/ProfilePanel.module.css index 607bfdc..a0ab02f 100644 --- a/renderer/src/styles/ProfilePanel.module.css +++ b/renderer/src/styles/ProfilePanel.module.css @@ -75,6 +75,9 @@ .statChip { font-size: 12px; color: var(--text-3); + display: flex; + align-items: center; + gap: 6px; } .statChipValue { @@ -83,6 +86,23 @@ font-variant-numeric: tabular-nums; } +.statChipSep { + color: rgba(255, 255, 255, 0.2); +} + +.signOutInline { + background: none; + border: none; + color: rgba(255, 80, 80, 0.6); + font-size: 12px; + font-weight: 500; + font-family: inherit; + padding: 0; + cursor: pointer; + transition: color 0.12s; +} +.signOutInline:hover { color: rgba(255, 80, 80, 1); } + /* ── Sections ────────────────────────────────────────────────────────────── */ .section { @@ -225,11 +245,6 @@ /* ── Skeletons ───────────────────────────────────────────────────────────── */ -@keyframes skelPulse { - 0%, 100% { opacity: 0.3; } - 50% { opacity: 0.55; } -} - .skelAvatar { width: 72px; height: 72px; @@ -256,50 +271,3 @@ .playlistRow::-webkit-scrollbar { height: 6px; } .playlistRow::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } -.playlistCard { - background: none; - border: none; - padding: 0; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; - cursor: pointer; - flex-shrink: 0; - width: 120px; - text-align: left; -} -.playlistCard:hover .playlistCardArt { opacity: 0.85; } - -.playlistCardArt { - width: 120px; - height: 120px; - border-radius: var(--r-md); - display: flex; - align-items: center; - justify-content: center; - font-size: 40px; - font-weight: 700; - color: rgba(255, 255, 255, 0.6); - transition: opacity 0.15s; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); -} - -.playlistCardName { - font-size: 12px; - font-weight: 600; - color: var(--text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 120px; -} - -.playlistCardMeta { - font-size: 11px; - color: var(--text-3); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 120px; -} diff --git a/renderer/src/styles/TopNav.module.css b/renderer/src/styles/TopNav.module.css index 1614dff..15f1e47 100644 --- a/renderer/src/styles/TopNav.module.css +++ b/renderer/src/styles/TopNav.module.css @@ -208,18 +208,65 @@ .groupOption:hover { background: var(--bg-2); color: var(--text); } .groupOptionActive { color: var(--text) !important; font-weight: 600; } -/* ── Name popover ── */ -.nameWrap { position: relative; } +/* ── Profile chip + popover ── */ +.profileWrap { position: relative; } -.namePopover { +.profileChip { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + padding: 4px 8px 4px 4px; + border-radius: 20px; + cursor: pointer; + transition: background 0.12s; + flex-shrink: 0; + max-width: 140px; +} +.profileChip:hover { background: rgba(255, 255, 255, 0.08); } +.profileChipOpen { background: rgba(255, 255, 255, 0.10); } +.profileChipActive { background: rgba(255, 255, 255, 0.08); } + +.profileAvatar { + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); + color: #fff; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.profileName { + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.75); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.profileSignIn { + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.5); + padding: 0 4px; +} + +.profilePopover { position: absolute; - top: calc(100% + 20px); - right: 0; + top: calc(100% + 16px); + left: 0; background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-md); padding: 12px; - width: 200px; + width: 220px; display: flex; flex-direction: column; gap: 8px; @@ -227,12 +274,85 @@ z-index: 300; } -.namePopoverLabel { +.profileHeader { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 4px; +} + +.profileAvatarLg { + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + color: #fff; + font-size: 15px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.profileHeaderName { + font-size: 14px; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profileViewBtn { + background: rgba(255, 255, 255, 0.07); + border: 1px solid var(--border); + border-radius: var(--r-sm); + color: var(--text-2); + font-size: 12px; + font-weight: 500; + font-family: inherit; + padding: 7px 10px; + cursor: pointer; + text-align: left; + transition: background 0.12s, color 0.12s; +} +.profileViewBtn:hover { background: rgba(255, 255, 255, 0.12); color: var(--text); } + +.profileDivider { + height: 1px; + background: var(--border); + margin: 0 -12px; +} + +.profileChangeSection { + display: flex; + flex-direction: column; + gap: 6px; +} + +.profileChangeLabel { font-size: 11px; color: var(--text-3); font-weight: 500; } +.profileSignOutBtn { + background: none; + border: none; + color: rgba(255, 80, 80, 0.75); + font-size: 12px; + font-weight: 500; + font-family: inherit; + padding: 4px 0; + cursor: pointer; + text-align: left; + transition: color 0.12s; +} +.profileSignOutBtn:hover { color: rgba(255, 80, 80, 1); } + +/* ── Name input (shared by profile popover) ── */ + .nameInput { background: var(--bg-2); border: 1px solid var(--border); diff --git a/renderer/src/styles/global.css b/renderer/src/styles/global.css index faf5f3c..92e3f87 100644 --- a/renderer/src/styles/global.css +++ b/renderer/src/styles/global.css @@ -94,3 +94,8 @@ html[data-mini] #root { ::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, 0.22); } + +@keyframes skelPulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.55; } +} From 4504d85aff887a9ccc7711c06bbbc31cd97271e4 Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 8 May 2026 19:59:02 +0100 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20profile=20page=20overhaul=20?= =?UTF-8?q?=E2=80=94=20top=20tracks=20grid,=20profile=20pic,=20playlists?= =?UTF-8?q?=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile picture upload: Azure Blob + Cosmos profiles container, camera emoji overlay, shown in TopNav chip - Top tracks: 2-column 5×5 grid with multi-select, drag-to-queue, clickable artist/album links - Queuedle rank promoted to header hero block with tier icon - Stats fetch count raised to 25 (server accepts ?count param) - Top artists/albums displayed at 110px for compact fit - Playlists section now wraps as CSS grid instead of horizontal scroll Co-Authored-By: Claude Sonnet 4.6 --- renderer/src/components/CardRow.tsx | 7 +- renderer/src/components/ProfilePanel.tsx | 293 +++++++++++------- renderer/src/components/TopNav.tsx | 60 ++-- .../components/common/PlaylistCard.module.css | 1 - renderer/src/hooks/useUserProfile.ts | 19 ++ renderer/src/hooks/useUserStats.ts | 10 +- renderer/src/styles/ProfilePanel.module.css | 100 ++++-- renderer/src/test/setup.ts | 2 + renderer/src/types/globals.d.ts | 10 +- server/azuredeploy.json | 14 + server/src/functions/profile-get.ts | 41 +++ server/src/functions/profile-upload-image.ts | 106 +++++++ server/src/functions/stats.ts | 7 +- src/main.ts | 25 +- src/preload.ts | 8 +- 15 files changed, 533 insertions(+), 170 deletions(-) create mode 100644 renderer/src/hooks/useUserProfile.ts create mode 100644 server/src/functions/profile-get.ts create mode 100644 server/src/functions/profile-upload-image.ts diff --git a/renderer/src/components/CardRow.tsx b/renderer/src/components/CardRow.tsx index 7472078..677cf9b 100644 --- a/renderer/src/components/CardRow.tsx +++ b/renderer/src/components/CardRow.tsx @@ -8,15 +8,18 @@ export function CardRow({ isLoading, onAdd, onOpen, + cardSize, }: { items: SonosItem[]; isLoading: boolean; onAdd: (item: SonosItem) => void; onOpen: (item: SonosItem) => void; + cardSize?: string; }) { + const sizeStyle = cardSize ? { '--card-size': cardSize } as React.CSSProperties : undefined; if (isLoading) { return ( -
+
{Array.from({ length: 6 }).map((_, i) => (
))} @@ -25,7 +28,7 @@ export function CardRow({ } if (!items.length) return null; return ( -
+
{items.map((item, i) => ( void; linkClass: string }) { + const artistItem: SonosItem | null = t.artistId + ? { type: 'ARTIST', name: t.artist, resource: { type: 'ARTIST', id: { objectId: t.artistId, serviceId: t.serviceId, accountId: t.accountId } } } + : null; + const albumItem: SonosItem | null = t.albumId && t.album + ? { type: 'ALBUM', name: t.album, resource: { type: 'ALBUM', id: { objectId: t.albumId, serviceId: t.serviceId, accountId: t.accountId } } } + : null; + return ( + <> + {artistItem + ? + : t.artist} + {t.album && <>{' · '}{albumItem + ? + : t.album}} + + ); +} + +function statsTrackToSonosItem(t: StatsTrack): SonosItem { + return { + type: 'TRACK', + title: t.trackName, + resource: { + type: 'TRACK', + id: { objectId: t.uri ?? '', serviceId: t.serviceId ?? '', accountId: t.accountId ?? '' }, + }, + }; +} + function getAvatarStyle(name: string): React.CSSProperties { let hash = 0; for (let i = 0; i < name.length; i++) { @@ -37,16 +69,59 @@ export function ProfilePanel({ onAddToQueue, displayName, onSignOut }: Props) { const openItem = useOpenItem(); const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); - const { artistItems: recentArtists, albumItems: recentAlbums, isLoading: recentLoading } = - useRecentlyPlayed(userName); - - const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, isLoading: statsLoading } = - useUserStats(userName); +const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, isLoading: statsLoading } = + useUserStats(userName, 25); const { data: rankings } = useGameRankings(userName, !!userName); const ranking = rankings?.find(r => r.userName === userName) ?? null; const isOwnProfile = !!displayName && displayName === userName; + const { data: profile } = useUserProfile(userName); + const profileArt = useImage(profile?.imageUrl ?? null); + const invalidateProfile = useInvalidateUserProfile(); + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [trackSelected, setTrackSelected] = useState>(new Set()); + const lastTrackSelected = useRef(null); + + const handleTrackClick = useCallback((idx: number, e: React.MouseEvent) => { + e.stopPropagation(); + if (e.shiftKey && lastTrackSelected.current !== null) { + const lo = Math.min(lastTrackSelected.current, idx); + const hi = Math.max(lastTrackSelected.current, idx); + setTrackSelected(prev => { const next = new Set(prev); for (let j = lo; j <= hi; j++) next.add(j); return next; }); + } else if (e.ctrlKey || e.metaKey) { + setTrackSelected(prev => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); + lastTrackSelected.current = idx; + } else { + if (trackSelected.size === 1 && trackSelected.has(idx)) { setTrackSelected(new Set()); lastTrackSelected.current = null; } + else { setTrackSelected(new Set([idx])); lastTrackSelected.current = idx; } + } + }, [trackSelected]); + + const handleTrackDragStart = useCallback((idx: number, e: React.DragEvent) => { + const indices = trackSelected.has(idx) ? [...trackSelected].sort((a, b) => a - b) : [idx]; + if (!trackSelected.has(idx)) { setTrackSelected(new Set([idx])); lastTrackSelected.current = idx; } + const items = indices.map(i => statsTrackToSonosItem(topTracks[i])); + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(items)); + createDragGhost(items.length > 1 ? `${items.length} tracks` : topTracks[idx].trackName, e.dataTransfer); + }, [trackSelected, topTracks]); + + async function handleAvatarUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file || !userName) return; + setUploading(true); + try { + const buf = await file.arrayBuffer(); + await window.sonos.uploadProfileImage(userName, buf, file.type); + invalidateProfile(userName); + } finally { + setUploading(false); + e.target.value = ''; + } + } + const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(userName); const visiblePlaylists = isOwnProfile ? [...ownedPlaylists, ...joinedPlaylists] @@ -55,17 +130,38 @@ export function ProfilePanel({ onAddToQueue, displayName, onSignOut }: Props) { if (!userName) return null; const tierIcon = ranking ? getGameRankIcon(ranking.tierKey) : null; - const hasRecent = recentLoading || recentArtists.length > 0 || recentAlbums.length > 0; return (
{/* ── Header ──────────────────────────────────────────────────────── */}
-
- {userName[0].toUpperCase()} +
fileInputRef.current?.click() : undefined} + title={isOwnProfile ? 'Change photo' : undefined} + > + {profileArt + ? + : userName[0].toUpperCase() + } + {isOwnProfile && ( +
+ {uploading ? '…' : '📷'} +
+ )} +
-

{userName}

+
+

{userName}

+
{(totalEvents > 0 || isOwnProfile) && ( {totalEvents > 0 && ( @@ -87,16 +183,85 @@ export function ProfilePanel({ onAddToQueue, displayName, onSignOut }: Props) { {ranking.isProvisional ? 'Provisional' : ranking.tierName} - {!ranking.isProvisional && ( - - {ranking.averagePercent.toFixed(0)}% avg · {ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} - - )} + + {ranking.isProvisional + ? <>{ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} played + : <>{ranking.averagePercent.toFixed(0)}% avg · best {ranking.bestTotal} · {ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} + } +
)}
+ {/* ── Top Tracks ───────────────────────────────────────────────────── */} + {(statsLoading || topTracks.length > 0) && ( +
+

Top Tracks

+
{ setTrackSelected(new Set()); lastTrackSelected.current = null; }} + > +
+ {statsLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : topTracks.slice(0, 5).map((t, i) => ( + } + artUrl={t.imageUrl} + trailing={{t.count}×} + isSelected={trackSelected.has(i)} + onClick={e => handleTrackClick(i, e)} + draggable={!!t.uri} + onDragStart={t.uri ? e => handleTrackDragStart(i, e) : undefined} + /> + )) + } +
+
+ {statsLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : topTracks.slice(5, 10).map((t, i) => ( + } + artUrl={t.imageUrl} + trailing={{t.count}×} + isSelected={trackSelected.has(i + 5)} + onClick={e => handleTrackClick(i + 5, e)} + draggable={!!t.uri} + onDragStart={t.uri ? e => handleTrackDragStart(i + 5, e) : undefined} + /> + )) + } +
+
+
+ )} + + {/* ── Top Artists ───────────────────────────────────────────────────── */} + {(statsLoading || topArtists.length > 0) && ( +
+

Top Artists

+ +
+ )} + + {/* ── Top Albums ────────────────────────────────────────────────────── */} + {(statsLoading || topAlbums.length > 0) && ( +
+

Top Albums

+ +
+ )} + {/* ── Playlists ────────────────────────────────────────────────────── */} {(visiblePlaylists.length > 0 || isOwnProfile) && (
@@ -118,102 +283,6 @@ export function ProfilePanel({ onAddToQueue, displayName, onSignOut }: Props) {
)} - {/* ── Recently Played ──────────────────────────────────────────────── */} - {hasRecent && ( -
-

Recently Played — Last 7 Days

- {recentLoading || recentArtists.length > 0 ? ( - <> -

Artists

- - - ) : null} - {recentLoading || recentAlbums.length > 0 ? ( - <> -

Albums

- - - ) : null} - {!recentLoading && recentArtists.length === 0 && recentAlbums.length === 0 && ( -

Nothing queued in the last 7 days

- )} -
- )} - - {/* ── Top Played All Time ──────────────────────────────────────────── */} -
-

Top Played — All Time

- - {(statsLoading || topTracks.length > 0) && ( - <> -

Tracks

-
- {statsLoading - ? Array.from({ length: 5 }).map((_, i) => ( -
- )) - : topTracks.slice(0, 10).map((t, i) => ( - {t.count}×} - /> - )) - } -
- - )} - - {(statsLoading || topArtists.length > 0) && ( - <> -

Artists

- - - )} - - {(statsLoading || topAlbums.length > 0) && ( - <> -

Albums

- - - )} - - {!statsLoading && topTracks.length === 0 && topArtists.length === 0 && topAlbums.length === 0 && ( -

No plays recorded yet

- )} -
- - {/* ── Queuedle Stats ───────────────────────────────────────────────── */} -
-

Queuedle

- {ranking ? ( - <> -
-
- {ranking.gamesPlayed} - Games -
-
- {ranking.averageTotal.toFixed(1)} - Avg Score -
-
- {ranking.bestTotal} - Best Score -
-
- {ranking.averagePercent.toFixed(0)}% - Avg % -
-
- - ) : ( -

No Queuedle data yet

- )} -
- {createPlaylistOpen && ( (null); // React Router sets window.history.state = { idx, key } on every navigation + const { data: profile } = useUserProfile(displayName ?? undefined); + const profileArt = useImage(profile?.imageUrl ?? null); + const histIdx = (window.history.state as { idx?: number } | null)?.idx ?? 0; const canGoBack = histIdx > 0; const canGoForward = histIdx < window.history.length - 1; @@ -171,7 +176,30 @@ export function TopNav({
- {/* Profile chip — left of search */} +
+ + setSearchText(e.target.value)} + onKeyDown={handleKeyDown} + disabled={!isAuthed} + /> + +
+ +
+ + {/* Profile chip — right of search */} {displayName !== undefined && (
{displayName ? ( @@ -180,7 +208,12 @@ export function TopNav({ onClick={() => navigate(`/profile/${encodeURIComponent(displayName)}`)} title="My profile" > - {displayName[0].toUpperCase()} + + {profileArt + ? + : displayName[0].toUpperCase() + } + {displayName} ) : ( @@ -225,29 +258,6 @@ export function TopNav({
)} -
- - setSearchText(e.target.value)} - onKeyDown={handleKeyDown} - disabled={!isAuthed} - /> - -
- -
- {isAuthed && groups.length === 0 ? ( + ); +} + function AlbumListItem({ item, onOpen, onAdd }: { item: SonosItem; onOpen: () => void; onAdd: () => void }) { const art = useImage(item.imageUrl); @@ -141,6 +161,7 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName const activeSearch = searchParams.get('q') ?? ''; const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(displayName); + const { data: colleagues = [], isLoading: colleaguesLoading } = useUsers(isAuthed); const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(undefined); @@ -226,6 +247,26 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName + {(colleaguesLoading || colleagues.length > 0) && ( +
+

People

+
+ {colleaguesLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
+ )) + : colleagues.map((u) => ( + navigate(`/profile/${encodeURIComponent(u.userId)}`)} + /> + )) + } +
+
+ )} + {displayName && (ownedPlaylists.length > 0 || joinedPlaylists.length > 0) && (
@@ -233,7 +274,12 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName
- {[...ownedPlaylists, ...joinedPlaylists].map((pl) => ( + {[...ownedPlaylists, ...joinedPlaylists] + .sort((a, b) => { + if (!!b.isFavourites !== !!a.isFavourites) return b.isFavourites ? 1 : -1; + return b.updatedAt - a.updatedAt; + }) + .map((pl) => ( void; + displayName?: string | null; } function ScrollingText({ @@ -139,7 +142,7 @@ function VolumeButton({ volume }: { volume: number }) { ); } -export function PlayerBar({ isAuthed, playback, onShuffle }: Props) { +export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) { const navigate = useNavigate(); const { pathname } = useLocation(); const lyricsActive = pathname === '/lyrics'; @@ -149,8 +152,18 @@ export function PlayerBar({ isAuthed, playback, onShuffle }: Props) { elapsedLabel, durationLabel, progressPct, durationMs, isPlaying, isVisible, shuffle, repeat, volume, isExplicit, albumItem, prefetchAlbum, + currentObjectId, currentServiceId, currentAccountId, artUrlRaw, } = useNowPlaying(playback); + const { isFavourited, toggle: toggleFavourite } = useFavourite(displayName, { + uri: currentObjectId, + trackName: displayTrack, + artist: displayArtist, + imageUrl: artUrlRaw, + serviceId: currentServiceId, + accountId: currentAccountId, + }); + const { shuffle: rawShuffle, repeat: rawRepeat } = playback; const handleSeek = (e: React.MouseEvent) => { @@ -294,6 +307,15 @@ export function PlayerBar({ isAuthed, playback, onShuffle }: Props) { {/* Right — volume + lyrics + queue + mini player */}
+ {displayName && currentObjectId && ( + + )}
{playlist.tracks.length > 0 && ( @@ -200,6 +340,16 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { {isMember ? 'Leave' : 'Join'} )} + {isOwner && !playlist.isFavourites && ( + confirmDelete ? ( + <> + + + + ) : ( + + ) + )}
) : ( @@ -238,21 +388,23 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) {
{playlist.tracks.map((t, i) => { - const sonosItem = trackToSonosItem(t); return ( - onAddToQueue(sonosItem, -1)} - onRemove={() => handleRemoveTrack(i)} - onContextMenu={e => showTrackMenu({ track: t, sonosItem }, e)} - onDragStart={e => { - e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify([sonosItem])); - }} - /> +
+ {dragOverIndex === i &&
} + onAddToQueue(trackToSonosItem(t), -1)} + onRemove={() => handleRemoveTrack(t.uri, i)} + onContextMenu={e => showTrackMenu({ track: t, sonosItem: trackToSonosItem(t) }, e)} + onDragStart={e => handleDragStart(i, e)} + onDragOver={e => handleDragOver(i, e)} + onDrop={e => handleDrop(i, e)} + onDragEnd={handleDragEnd} + /> +
); })} diff --git a/renderer/src/components/ProfilePanel.tsx b/renderer/src/components/ProfilePanel.tsx index 2f5a662..e3bd378 100644 --- a/renderer/src/components/ProfilePanel.tsx +++ b/renderer/src/components/ProfilePanel.tsx @@ -123,9 +123,13 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, } const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(userName); - const visiblePlaylists = isOwnProfile + const allPlaylists = isOwnProfile ? [...ownedPlaylists, ...joinedPlaylists] : ownedPlaylists.filter(p => p.isPublic); + const visiblePlaylists = [...allPlaylists].sort((a, b) => { + if (!!b.isFavourites !== !!a.isFavourites) return b.isFavourites ? 1 : -1; + return b.updatedAt - a.updatedAt; + }); if (!userName) return null; @@ -276,6 +280,7 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, navigate(`/playlist/${pl.id}`)} /> ))} diff --git a/renderer/src/components/TopNav.tsx b/renderer/src/components/TopNav.tsx index 1db1012..d39c780 100644 --- a/renderer/src/components/TopNav.tsx +++ b/renderer/src/components/TopNav.tsx @@ -99,11 +99,6 @@ export function TopNav({ } } - function signOut() { - onSaveName(''); - setProfileOpen(false); - } - useEffect(() => { if (location.pathname === '/search') { setSearchText(searchParams.get('q') ?? ''); diff --git a/renderer/src/components/__tests__/PlaylistPanel.test.tsx b/renderer/src/components/__tests__/PlaylistPanel.test.tsx new file mode 100644 index 0000000..ef2f0a5 --- /dev/null +++ b/renderer/src/components/__tests__/PlaylistPanel.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { PlaylistPanel } from '../PlaylistPanel'; +import type { SonosItem } from '../../types/sonos'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useParams: () => ({ id: PLAYLIST_ID }), + useNavigate: () => mockNavigate, +})); + +vi.mock('../../hooks/useImage', () => ({ + useImage: () => null, +})); + +vi.mock('../../hooks/useTrackDetails', () => ({ + useTrackDetails: () => ({ data: null }), +})); + +const mockShowTrackMenu = vi.fn(); +vi.mock('../common/ContextMenu', () => ({ + useTrackContextMenu: () => ({ showTrackMenu: mockShowTrackMenu }), +})); + +const mockShowToast = vi.fn(); +vi.mock('../common/Toast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})); + +const PLAYLIST_ID = 'playlist-1'; +const OWNER = 'Alice'; +const TRACK_URI = 'x-sonos-spotify:spotify:track:abc123'; + +function makeTrack(): PlaylistTrack { + return { + uri: TRACK_URI, + trackName: 'Test Track', + artist: 'Test Artist', + imageUrl: null, + serviceId: 'gm', + accountId: 'acc1', + addedBy: OWNER, + addedAt: 0, + }; +} + +function makePlaylist(tracks: PlaylistTrack[] = []): PlaylistDoc { + return { + id: PLAYLIST_ID, + name: 'My Playlist', + owner: OWNER, + isPublic: false, + isFavourites: false, + members: [OWNER], + memberCount: 1, + trackCount: tracks.length, + tracks, + createdAt: 1000, + updatedAt: 1000, + }; +} + +function renderPanel(qc: QueryClient) { + return render( + createElement( + QueryClientProvider, + { client: qc }, + createElement(PlaylistPanel, { + displayName: OWNER, + onAddToQueue: vi.fn() as (item: SonosItem, position?: number) => void, + }), + ), + ); +} + +function makeQc() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('handleRemoveTrack', () => { + it('calls removeTrackFromPlaylist with correct args and removes track from cache', async () => { + const qc = makeQc(); + qc.setQueryData(['playlist', PLAYLIST_ID], makePlaylist([makeTrack()])); + vi.mocked(window.sonos.removeTrackFromPlaylist).mockResolvedValueOnce({} as PlaylistDoc); + + renderPanel(qc); + + const removeBtn = await screen.findByTitle('Remove'); + fireEvent.click(removeBtn); + + await waitFor(() => { + expect(vi.mocked(window.sonos.removeTrackFromPlaylist)).toHaveBeenCalledWith( + PLAYLIST_ID, + TRACK_URI, + ); + }); + + const updated = qc.getQueryData(['playlist', PLAYLIST_ID]); + expect(updated?.tracks).toHaveLength(0); + }); + + it('restores cache and shows toast when server rejects', async () => { + const qc = makeQc(); + qc.setQueryData(['playlist', PLAYLIST_ID], makePlaylist([makeTrack()])); + vi.mocked(window.sonos.removeTrackFromPlaylist).mockRejectedValueOnce(new Error('network')); + + renderPanel(qc); + + const removeBtn = await screen.findByTitle('Remove'); + fireEvent.click(removeBtn); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('Failed to remove track'); + }); + + const restored = qc.getQueryData(['playlist', PLAYLIST_ID]); + expect(restored?.tracks).toHaveLength(1); + }); +}); diff --git a/renderer/src/components/__tests__/TopNav.test.tsx b/renderer/src/components/__tests__/TopNav.test.tsx index de72b9e..ef9cb57 100644 --- a/renderer/src/components/__tests__/TopNav.test.tsx +++ b/renderer/src/components/__tests__/TopNav.test.tsx @@ -13,6 +13,14 @@ vi.mock('react-router-dom', () => ({ useSearchParams: () => mockUseSearchParams(), })); +vi.mock('../../hooks/useUserProfile', () => ({ + useUserProfile: () => ({ data: null, isLoading: false }), +})); + +vi.mock('../../hooks/useImage', () => ({ + useImage: () => null, +})); + const defaultProps = { isAuthed: true, groups: [{ id: 'g1', name: 'Living Room', coordinatorId: 'g1', providerId: 'sonos' as const }], @@ -90,22 +98,20 @@ describe('TopNav', () => { expect(onChangelogOpen).toHaveBeenCalled(); }); - it('shows user name popover when User icon is clicked', async () => { + it('navigates to profile page when profile chip is clicked (signed in)', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByTitle('Alice')); - expect(screen.getByText('Display name')).toBeInTheDocument(); + await user.click(screen.getByTitle('My profile')); + expect(mockNavigate).toHaveBeenCalledWith('/profile/Alice'); }); - it('calls onSaveName with trimmed name when Save is clicked', async () => { + it('calls onSaveName with trimmed name when Sign in is submitted (unauthenticated)', async () => { const onSaveName = vi.fn(); const user = userEvent.setup(); - render(); - await user.click(screen.getByTitle('Alice')); - const input = screen.getByDisplayValue('Alice'); - await user.clear(input); - await user.type(input, 'Bob'); - await user.click(screen.getByText('Save')); + render(); + await user.click(screen.getByTitle('Sign in')); + await user.type(screen.getByPlaceholderText('Your name'), 'Bob'); + await user.click(screen.getAllByText('Sign in')[1]); expect(onSaveName).toHaveBeenCalledWith('Bob'); }); @@ -127,10 +133,10 @@ describe('TopNav', () => { expect(onResync).toHaveBeenCalled(); }); - it('shows app version in name popover after load', async () => { + it('shows app version in sign-in popover after load (unauthenticated)', async () => { const user = userEvent.setup(); - render(); - await user.click(screen.getByTitle('Alice')); + render(); + await user.click(screen.getByTitle('Sign in')); await waitFor(() => expect(screen.getByText('v1.0.0')).toBeInTheDocument()); }); diff --git a/renderer/src/components/common/ContextMenu.tsx b/renderer/src/components/common/ContextMenu.tsx index 2f02f6a..2d693d1 100644 --- a/renderer/src/components/common/ContextMenu.tsx +++ b/renderer/src/components/common/ContextMenu.tsx @@ -2,7 +2,8 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { useMyPlaylists } from '../../hooks/usePlaylists'; +import { useMyPlaylists, favouritesId } from '../../hooks/usePlaylists'; +import { useToast } from './Toast'; import type { SonosItem } from '../../types/sonos'; import styles from '../../styles/ContextMenu.module.css'; @@ -55,6 +56,7 @@ function PlaylistSubmenu({ }) { const { owned, joined, isLoading } = useMyPlaylists(displayName); const queryClient = useQueryClient(); + const { showToast } = useToast(); const ref = useRef(null); const allPlaylists = [...owned, ...joined]; @@ -63,8 +65,12 @@ function PlaylistSubmenu({ try { await window.sonos.addTrackToPlaylist(playlist.id, track); queryClient.invalidateQueries({ queryKey: ['playlist', playlist.id] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', playlist.owner] }); + if (displayName && playlist.owner !== displayName) { + queryClient.invalidateQueries({ queryKey: ['playlists', 'joined', displayName] }); + } } catch { - // silent + showToast(`Failed to add to ${playlist.name}`); } onClose(); } @@ -107,6 +113,8 @@ function PlaylistSubmenu({ export function ContextMenuProvider({ children, displayName, onAddToQueue }: ProviderProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); const [menuState, setMenuState] = useState({ open: false, x: 0, y: 0, payload: null, submenuOpen: false, submenuAnchor: null, }); @@ -156,6 +164,28 @@ export function ContextMenuProvider({ children, displayName, onAddToQueue }: Pro close(); } + async function handleAddToFavourites() { + if (!menuState.payload || !displayName) return; + const favId = favouritesId(displayName); + const track = menuState.payload.track; + const snapshot = queryClient.getQueryData(['playlist', favId]); + if (snapshot) { + queryClient.setQueryData(['playlist', favId], { + ...snapshot, + tracks: [...snapshot.tracks, track], + }); + } + try { + await window.sonos.addTrackToPlaylist(favId, track); + queryClient.invalidateQueries({ queryKey: ['playlist', favId] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + if (snapshot) queryClient.setQueryData(['playlist', favId], snapshot); + showToast('Failed to add to Favourites'); + } + close(); + } + function handleOpenPlaylistSubmenu(e: React.MouseEvent) { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setMenuState((s) => ({ @@ -195,6 +225,12 @@ export function ContextMenuProvider({ children, displayName, onAddToQueue }: Pro
)} + {displayName && ( +
+ ❤️ + Add to Favourites +
+ )}
Add to playlist @@ -263,18 +299,13 @@ export function CreatePlaylistDialog({ setErrorMsg(''); try { const playlist = await window.sonos.createPlaylist(name.trim(), isPublic); - if (!playlist || typeof playlist !== 'object' || 'error' in playlist) { - setErrorMsg((playlist as { error?: string }).error ?? 'Failed to create playlist'); - setBusy(false); - return; - } - if (pendingTrack && (playlist as PlaylistDoc).id) { - await window.sonos.addTrackToPlaylist((playlist as PlaylistDoc).id, pendingTrack); + if (pendingTrack && playlist.id) { + await window.sonos.addTrackToPlaylist(playlist.id, pendingTrack); } queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); onClose(); } catch (err) { - setErrorMsg(String(err)); + setErrorMsg(err instanceof Error ? err.message : String(err)); setBusy(false); } } diff --git a/renderer/src/components/common/PlaylistCard.tsx b/renderer/src/components/common/PlaylistCard.tsx index 660c7ba..f612fde 100644 --- a/renderer/src/components/common/PlaylistCard.tsx +++ b/renderer/src/components/common/PlaylistCard.tsx @@ -14,11 +14,11 @@ export function PlaylistCard({ pl, displayName, onClick }: Props) {