diff --git a/assets/AlgorithWhiperer-notext.png b/assets/AlgorithWhiperer-notext.png new file mode 100644 index 0000000..42054f2 Binary files /dev/null and b/assets/AlgorithWhiperer-notext.png differ diff --git a/assets/AlgorithmWhisperer.png b/assets/AlgorithmWhisperer.png new file mode 100644 index 0000000..4389b44 Binary files /dev/null and b/assets/AlgorithmWhisperer.png differ diff --git a/assets/Aux Cable Apprentice.png b/assets/Aux Cable Apprentice.png new file mode 100644 index 0000000..b3066cb Binary files /dev/null and b/assets/Aux Cable Apprentice.png differ diff --git a/assets/BackgroundBopper.png b/assets/BackgroundBopper.png new file mode 100644 index 0000000..4ace12c Binary files /dev/null and b/assets/BackgroundBopper.png differ diff --git a/assets/PlaylistProphet.png b/assets/PlaylistProphet.png new file mode 100644 index 0000000..0ffd2e8 Binary files /dev/null and b/assets/PlaylistProphet.png differ diff --git a/assets/Skip Button Survivor.png b/assets/Skip Button Survivor.png new file mode 100644 index 0000000..0131894 Binary files /dev/null and b/assets/Skip Button Survivor.png differ diff --git a/assets/auxcableapprentice-notext.png b/assets/auxcableapprentice-notext.png new file mode 100644 index 0000000..9a9b77b Binary files /dev/null and b/assets/auxcableapprentice-notext.png differ diff --git a/assets/backgroundbooper-notext.png b/assets/backgroundbooper-notext.png new file mode 100644 index 0000000..201dbdf Binary files /dev/null and b/assets/backgroundbooper-notext.png differ diff --git a/assets/playlistprophet-notext.png b/assets/playlistprophet-notext.png new file mode 100644 index 0000000..eb074f5 Binary files /dev/null and b/assets/playlistprophet-notext.png differ diff --git a/assets/skipbuttonsurvivor-notext.png b/assets/skipbuttonsurvivor-notext.png new file mode 100644 index 0000000..059ecdc Binary files /dev/null and b/assets/skipbuttonsurvivor-notext.png differ diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index 1387cae..8bc2319 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -89,8 +89,12 @@ function MainApp() { const [queueMode, setQueueMode] = useState<'floating' | 'docked'>('floating'); const [queueDockedWidth, setQueueDockedWidth] = useState(380); const queueSidebarRef = useRef(null); + const shellRef = useRef(null); + const handleResizeWidthLive = useCallback((width: number) => { + shellRef.current?.style.setProperty('--docked-queue-w', `${width}px`); + }, []); - useEffect(() => { +useEffect(() => { window.sonos.getDisplayName().then(setDisplayName); window.sonos.getQueueMode().then(setQueueMode).catch(() => {}); window.sonos.getQueueDockedWidth().then(setQueueDockedWidth).catch(() => {}); @@ -99,7 +103,7 @@ function MainApp() { useEffect(() => { if (queueMode !== 'docked') return; function clamp() { - const max = Math.min(700, window.innerWidth - 320); + const max = window.innerWidth - (800 + 64); setQueueDockedWidth((w) => (w > max ? max : w)); } window.addEventListener('resize', clamp); @@ -436,7 +440,11 @@ function MainApp() { const splashReady = isAuthed && groups.length > 0 && !ytmLoading && !histLoading; return ( -
+
)}
diff --git a/renderer/src/components/LeaderboardPanel.tsx b/renderer/src/components/LeaderboardPanel.tsx index caa695f..af0bb3d 100644 --- a/renderer/src/components/LeaderboardPanel.tsx +++ b/renderer/src/components/LeaderboardPanel.tsx @@ -1,7 +1,11 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { Info, X } from 'lucide-react'; +import type { GameRankTierKey } from '../hooks/useDailyGame'; import { useStats, StatsPeriod } from '../hooks/useStats'; +import { useGameRankings } from '../hooks/useDailyGame'; import { useImage } from '../hooks/useImage'; +import { getGameRankIcon, getGameRankInfoImage } from '../lib/gameRankAssets'; import { useResolveAndOpen } from '../hooks/useResolveAndOpen'; import styles from '../styles/LeaderboardPanel.module.css'; @@ -19,6 +23,18 @@ const PERIODS: { value: StatsPeriod; label: string }[] = [ const MEDALS = ['πŸ₯‡', 'πŸ₯ˆ', 'πŸ₯‰']; +const QUEUEDLE_RANK_INFO: Array<{ + key: Exclude; + name: string; + range: string; +}> = [ + { key: 'skip-button-survivor', name: 'Skip Button Survivor', range: '< 40%' }, + { key: 'background-bopper', name: 'Background Bopper', range: '40% - 54.9%' }, + { key: 'aux-cable-apprentice', name: 'Aux Cable Apprentice', range: '55% - 69.9%' }, + { key: 'algorithm-whisperer', name: 'Algorithm Whisperer', range: '70% - 84.9%' }, + { key: 'playlist-prophet', name: 'Playlist Prophet', range: '85%+' }, +]; + function makeDragItem(t: StatsTrack) { return JSON.stringify([ { @@ -33,12 +49,18 @@ function makeDragItem(t: StatsTrack) { export function LeaderboardPanel() { 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; + const isQueuedleView = view === 'queuedle'; + const queuedleRows = rankings.data ?? []; + const maxQueuedleAverage = queuedleRows[0]?.averageTotal ?? 1; return (
@@ -55,23 +77,91 @@ export function LeaderboardPanel() { {PERIODS.map((p) => ( ))} +
- +
- {isLoading &&
Loading…
} - {(error || data?.error) &&
{data?.error ?? 'Failed to load stats'}
} + {isQueuedleView && rankings.isLoading &&
Loading Queuedle rankings…
} + {isQueuedleView && rankings.error &&
Failed to load Queuedle rankings
} + {!isQueuedleView && isLoading &&
Loading…
} + {!isQueuedleView && (error || data?.error) && ( +
{data?.error ?? 'Failed to load stats'}
+ )} - {data && !data.error && !isLoading && ( -
+ {isQueuedleView && !rankings.isLoading && !rankings.error && ( +
+
+

Queuedle all-time average

+ {queuedleRows.length === 0 ? ( +
No Queuedle scores yet
+ ) : ( + queuedleRows.slice(0, 25).map((r, i) => ( +
+ + {i < 3 ? MEDALS[i] : {i + 1}} + + {r.userName} +
+
+
+ + {r.gamesPlayed} {r.gamesPlayed === 1 ? 'game' : 'games'} + + + {getGameRankIcon(r.tierKey) && ( + + )} + {r.tierName} + + {r.averageTotal.toFixed(1)} +
+ )) + )} +
+
+ )} + + {!isQueuedleView && data && !data.error && !isLoading && ( +
{/* ── Top queuers (leaderboard view only) ── */} {!selectedUser && (
@@ -288,6 +378,50 @@ export function LeaderboardPanel() {
)} + + {rankInfoOpen && ( +
setRankInfoOpen(false)}> +
e.stopPropagation()} + > +
+

+ Queuedle Rank Tiers +

+ +
+

+ Rank tiers use your all-time Queuedle percentage: total points earned divided by total possible points + across games played. You need 3 played days before a tier unlocks. +

+
+ {QUEUEDLE_RANK_INFO.map((tier) => { + const infoImage = getGameRankInfoImage(tier.key); + return ( +
+ {infoImage && } + {tier.name} + {tier.range} +
+ ); + })} +
+
+ Fewer than 3 played days = Provisional (no tier) +
+
+
+ )} ); } diff --git a/renderer/src/components/PlayerBar.tsx b/renderer/src/components/PlayerBar.tsx index 3961cb7..c148bec 100644 --- a/renderer/src/components/PlayerBar.tsx +++ b/renderer/src/components/PlayerBar.tsx @@ -305,9 +305,11 @@ export function PlayerBar({ isAuthed, playback, onToggleQueue, onShuffle, queueM > - + {queueMode !== 'docked' && ( + + )} )} - + {queueMode !== 'docked' && ( + + )} diff --git a/renderer/src/components/__tests__/LeaderboardPanel.test.tsx b/renderer/src/components/__tests__/LeaderboardPanel.test.tsx index 687bbc0..a636df1 100644 --- a/renderer/src/components/__tests__/LeaderboardPanel.test.tsx +++ b/renderer/src/components/__tests__/LeaderboardPanel.test.tsx @@ -21,7 +21,17 @@ function mockSearchResolves(opts: { resourceOrder: ['ARTISTS', 'ALBUMS'], ARTISTS: { resources: opts.artist - ? [{ id: { objectId: opts.artist.objectId, serviceId: opts.artist.serviceId, accountId: opts.artist.accountId }, name: opts.artist.name, images: [] }] + ? [ + { + id: { + objectId: opts.artist.objectId, + serviceId: opts.artist.serviceId, + accountId: opts.artist.accountId, + }, + name: opts.artist.name, + images: [], + }, + ] : [], }, ALBUMS: { @@ -30,7 +40,12 @@ function mockSearchResolves(opts: { name: a.name, images: [], artists: a.artistName - ? [{ name: a.artistName, id: { objectId: `${a.objectId}-artist`, serviceId: a.serviceId, accountId: a.accountId } }] + ? [ + { + name: a.artistName, + id: { objectId: `${a.objectId}-artist`, serviceId: a.serviceId, accountId: a.accountId }, + }, + ] : [], })), }, @@ -39,6 +54,7 @@ function mockSearchResolves(opts: { } const mockNavigate = vi.fn(); +const mockUseGameRankings = vi.fn(); vi.mock('react-router-dom', async (importActual) => { const actual = await importActual(); return { ...actual, useNavigate: () => mockNavigate }; @@ -56,6 +72,7 @@ vi.mock('../../hooks/useImage', () => ({ vi.mock('../../hooks/useDailyGame', () => ({ useGameLeaderboard: () => ({ data: { gameId: 'today', scores: [] }, isLoading: false }), + useGameRankings: (userName: string | null | undefined, enabled: boolean) => mockUseGameRankings(userName, enabled), useDailyGame: () => ({ data: undefined, isLoading: false }), useSubmitGameScore: () => ({ mutateAsync: vi.fn(), isPending: false }), dailyGameQueryOptions: () => ({ queryKey: [], queryFn: vi.fn() }), @@ -66,14 +83,21 @@ const mockRefetch = vi.fn(); const mockData: StatsResult = { topUsers: [ { userId: 'alice', count: 10 }, - { userId: 'bob', count: 7 }, + { userId: 'bob', count: 7 }, ], topTracks: [ - { trackName: 'Bohemian Rhapsody', artist: 'Queen', count: 5, artistId: 'art1', serviceId: 'svc1', accountId: 'acc1' }, - { trackName: 'Hotel California', artist: 'Eagles', count: 3 }, + { + trackName: 'Bohemian Rhapsody', + artist: 'Queen', + count: 5, + artistId: 'art1', + serviceId: 'svc1', + accountId: 'acc1', + }, + { trackName: 'Hotel California', artist: 'Eagles', count: 3 }, ], topArtists: [ - { artist: 'Queen', artistId: 'art1', serviceId: 'svc1', accountId: 'acc1', count: 5 }, + { artist: 'Queen', artistId: 'art1', serviceId: 'svc1', accountId: 'acc1', count: 5 }, { artist: 'Eagles', count: 3 }, ], topAlbums: [ @@ -95,6 +119,7 @@ function mockLoaded(data: StatsResult = mockData) { beforeEach(() => { vi.clearAllMocks(); mockLoaded(); + mockUseGameRankings.mockReturnValue({ data: [], isLoading: false, error: null, refetch: vi.fn() }); }); describe('LeaderboardPanel', () => { @@ -143,6 +168,7 @@ describe('LeaderboardPanel', () => { expect(screen.getByRole('button', { name: 'Today' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'This week' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'All time' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Queuedle' })).toBeInTheDocument(); }); it('renders top queuers', () => { @@ -188,6 +214,62 @@ describe('LeaderboardPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'All time' })); expect(mockUseStats).toHaveBeenCalledWith('alltime', undefined); }); + + it('switches to Queuedle all-time average rankings', () => { + mockUseGameRankings.mockReturnValue({ + data: [ + { + userName: 'queuedle-player', + gamesPlayed: 3, + averageTotal: 4.666, + averageMain: 3, + averageBonus: 1.666, + averagePercent: 77.7, + bestTotal: 6, + tierKey: 'algorithm-whisperer', + tierName: 'Algorithm Whisperer', + isProvisional: false, + }, + ], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Queuedle' })); + expect(screen.getByText('Queuedle all-time average')).toBeInTheDocument(); + expect(screen.getByText('queuedle-player')).toBeInTheDocument(); + expect(screen.getByText('3 games')).toBeInTheDocument(); + expect(screen.getByText('Algorithm Whisperer')).toBeInTheDocument(); + expect(screen.getByText('4.7')).toBeInTheDocument(); + expect(container.querySelector('.rankTierIcon')).toBeTruthy(); + expect(mockUseGameRankings).toHaveBeenLastCalledWith(null, true); + }); + + it('shows provisional tier in Queuedle rankings', () => { + mockUseGameRankings.mockReturnValue({ + data: [ + { + userName: 'new-player', + gamesPlayed: 2, + averageTotal: 5, + averageMain: 3, + averageBonus: 2, + averagePercent: 90, + bestTotal: 6, + tierKey: 'provisional', + tierName: 'Provisional', + isProvisional: true, + }, + ], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Queuedle' })); + expect(screen.getByText('Provisional')).toBeInTheDocument(); + }); }); describe('refresh button', () => { @@ -198,6 +280,21 @@ describe('LeaderboardPanel', () => { }); }); + describe('rank info', () => { + it('opens and closes the Queuedle rank tier info dialog', () => { + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Rank info' })); + expect(screen.getByRole('dialog', { name: 'Queuedle Rank Tiers' })).toBeInTheDocument(); + expect(screen.getByText(/total points earned divided by total possible points/i)).toBeInTheDocument(); + expect(screen.getByText('Skip Button Survivor')).toBeInTheDocument(); + expect(screen.getByText('Playlist Prophet')).toBeInTheDocument(); + expect(screen.getByText('85%+')).toBeInTheDocument(); + expect(container.querySelectorAll('.rankInfoImage')).toHaveLength(5); + fireEvent.click(screen.getByRole('button', { name: 'Close rank info' })); + expect(screen.queryByRole('dialog', { name: 'Queuedle Rank Tiers' })).not.toBeInTheDocument(); + }); + }); + describe('user drill-down', () => { it('navigates to user stats when a user row is clicked', () => { render(); @@ -239,10 +336,7 @@ describe('LeaderboardPanel', () => { render(); // Queen has artistId 'art1' fireEvent.click(screen.getAllByText('Queen')[0]); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/artist\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/artist\//), expect.anything()); }); it('clicking an artist link without artistId resolves via search and opens artist page', async () => { @@ -251,10 +345,7 @@ describe('LeaderboardPanel', () => { // Eagles has no artistId β€” fall back to a Sonos search that resolves to a real artist fireEvent.click(screen.getAllByText('Eagles')[0]); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/artist\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/artist\//), expect.anything()); }); }); @@ -283,33 +374,36 @@ describe('LeaderboardPanel', () => { ...mockData, topAlbums: [], topTracks: [ - { trackName: 'My Song', artist: 'Artist', count: 5, artistId: 'art1', serviceId: 'svc1', accountId: 'acc1', album: 'Great Album', albumId: 'alb9' }, + { + trackName: 'My Song', + artist: 'Artist', + count: 5, + artistId: 'art1', + serviceId: 'svc1', + accountId: 'acc1', + album: 'Great Album', + albumId: 'alb9', + }, ], }); render(); fireEvent.click(screen.getByText('Great Album')); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/album\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/album\//), expect.anything()); }); it('clicking album link without albumId resolves via search and opens album page', async () => { mockLoaded({ ...mockData, topAlbums: [], - topTracks: [ - { trackName: 'Track', artist: 'Artist', count: 1, album: 'Rare Album' }, - ], + topTracks: [{ trackName: 'Track', artist: 'Artist', count: 1, album: 'Rare Album' }], + }); + mockSearchResolves({ + album: { name: 'Rare Album', objectId: 'rare-id', serviceId: 'svc1', accountId: 'acc1', artistName: 'Artist' }, }); - mockSearchResolves({ album: { name: 'Rare Album', objectId: 'rare-id', serviceId: 'svc1', accountId: 'acc1', artistName: 'Artist' } }); render(); fireEvent.click(screen.getByText('Rare Album')); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/album\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/album\//), expect.anything()); }); }); }); @@ -336,10 +430,7 @@ describe('LeaderboardPanel', () => { render(); // Queen appears in both topTracks and topArtists β€” click the second instance (topArtists) fireEvent.click(screen.getAllByText('Queen')[1]); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/artist\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/artist\//), expect.anything()); }); it('clicking artist without artistId resolves via search and opens artist page', async () => { @@ -348,10 +439,7 @@ describe('LeaderboardPanel', () => { // Eagles appears in both topTracks and topArtists β€” click the second instance (topArtists) fireEvent.click(screen.getAllByText('Eagles')[1]); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/artist\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/artist\//), expect.anything()); }); }); }); @@ -361,16 +449,23 @@ describe('LeaderboardPanel', () => { render(); // Click the album title in the top albums section fireEvent.click(screen.getByText('A Night at the Opera')); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/album\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/album\//), expect.anything()); }); it('shows art image when album has imageUrl', () => { mockLoaded({ ...mockData, - topAlbums: [{ album: 'Pictured Album', artist: 'Band', albumId: 'alb2', serviceId: 'svc1', accountId: 'acc1', count: 1, imageUrl: 'http://img.com/art.jpg' }], + topAlbums: [ + { + album: 'Pictured Album', + artist: 'Band', + albumId: 'alb2', + serviceId: 'svc1', + accountId: 'acc1', + count: 1, + imageUrl: 'http://img.com/art.jpg', + }, + ], }); const { container } = render(); expect(container.querySelector('img[src="http://img.com/art.jpg"]')).toBeTruthy(); @@ -381,14 +476,13 @@ describe('LeaderboardPanel', () => { ...mockData, topAlbums: [{ album: 'No Artist Album', artist: 'Unknown Artist', albumId: 'alb3', count: 1 }], }); - mockSearchResolves({ artist: { name: 'Unknown Artist', objectId: 'unk-id', serviceId: 'svc1', accountId: 'acc1' } }); + mockSearchResolves({ + artist: { name: 'Unknown Artist', objectId: 'unk-id', serviceId: 'svc1', accountId: 'acc1' }, + }); render(); fireEvent.click(screen.getByText('Unknown Artist')); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/artist\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/artist\//), expect.anything()); }); }); @@ -398,15 +492,18 @@ describe('LeaderboardPanel', () => { topAlbums: [{ album: 'Legacy Album', artist: 'Legacy Artist', count: 2 }], }); mockSearchResolves({ - album: { name: 'Legacy Album', objectId: 'legacy-id', serviceId: 'svc1', accountId: 'acc1', artistName: 'Legacy Artist' }, + album: { + name: 'Legacy Album', + objectId: 'legacy-id', + serviceId: 'svc1', + accountId: 'acc1', + artistName: 'Legacy Artist', + }, }); render(); fireEvent.click(screen.getByText('Legacy Album')); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/album\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/album\//), expect.anything()); }); }); @@ -418,17 +515,20 @@ describe('LeaderboardPanel', () => { // Search returns two same-named albums; the artist hint must pick Queen, not Eagles. mockSearchResolves({ albums: [ - { name: 'Greatest Hits', objectId: 'eagles-album', serviceId: 'svc1', accountId: 'acc1', artistName: 'Eagles' }, - { name: 'Greatest Hits', objectId: 'queen-album', serviceId: 'svc1', accountId: 'acc1', artistName: 'Queen' }, + { + name: 'Greatest Hits', + objectId: 'eagles-album', + serviceId: 'svc1', + accountId: 'acc1', + artistName: 'Eagles', + }, + { name: 'Greatest Hits', objectId: 'queen-album', serviceId: 'svc1', accountId: 'acc1', artistName: 'Queen' }, ], }); render(); fireEvent.click(screen.getByText('Greatest Hits')); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringContaining('queen-album'), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('queen-album'), expect.anything()); }); }); }); diff --git a/renderer/src/components/album/AlbumPanel.tsx b/renderer/src/components/album/AlbumPanel.tsx index 0aa5f28..b1b0812 100644 --- a/renderer/src/components/album/AlbumPanel.tsx +++ b/renderer/src/components/album/AlbumPanel.tsx @@ -52,7 +52,7 @@ export function AlbumPanel({ onAddToQueue, queueOpen }: Props) { const artist = data?.artist ?? ((item as Record)?.['subtitle'] as string) ?? ''; const artUrl = data?.artUrl ?? (item ? getItemArt(item) : null); const cachedArt = useImage(artUrl); - const dominantColor = useDominantColor(cachedArt); + const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); useEffect(() => { setSelected(new Set()); diff --git a/renderer/src/components/artist/ArtistHero.tsx b/renderer/src/components/artist/ArtistHero.tsx index ea43974..7bc36af 100644 --- a/renderer/src/components/artist/ArtistHero.tsx +++ b/renderer/src/components/artist/ArtistHero.tsx @@ -24,7 +24,7 @@ export function ArtistHero({ }); const cachedArt = useImage(getItemArt(artist)); - const dominantColor = useDominantColor(cachedArt); + const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); const name = (artist.title ?? artist.name ?? '') as string; const [selected, setSelected] = useState>(new Set()); diff --git a/renderer/src/components/artist/ArtistPanel.tsx b/renderer/src/components/artist/ArtistPanel.tsx index aed8a8c..eeefbd2 100644 --- a/renderer/src/components/artist/ArtistPanel.tsx +++ b/renderer/src/components/artist/ArtistPanel.tsx @@ -73,7 +73,7 @@ export function ArtistPanel({ onAddToQueue }: Props) { } const cachedArt = useImage(imageUrl); - const dominantColor = useDominantColor(cachedArt); + const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); const artistRadio = data?.playlists.find(p => (p.title as string)?.toLowerCase().includes('radio')); const latestAlbum = data?.albums[0] ?? null; diff --git a/renderer/src/components/queue/DraggableQueueRow.tsx b/renderer/src/components/queue/DraggableQueueRow.tsx index 73cb164..317400f 100644 --- a/renderer/src/components/queue/DraggableQueueRow.tsx +++ b/renderer/src/components/queue/DraggableQueueRow.tsx @@ -51,6 +51,11 @@ export function DraggableQueueRow({ ].filter(Boolean).join(' ')} data-playing={isPlaying ? 'true' : undefined} draggable + onPointerMove={e => { + const r = e.currentTarget.getBoundingClientRect(); + e.currentTarget.style.setProperty('--mx', `${e.clientX - r.left}px`); + e.currentTarget.style.setProperty('--my', `${e.clientY - r.top}px`); + }} onClick={e => onRowClick(index, e)} onDoubleClick={() => getActiveProvider().skipToTrack(index + 1)} onDragStart={e => onDragStart(index, e)} diff --git a/renderer/src/components/queue/QueueSidebar.tsx b/renderer/src/components/queue/QueueSidebar.tsx index 70528e7..aa30736 100644 --- a/renderer/src/components/queue/QueueSidebar.tsx +++ b/renderer/src/components/queue/QueueSidebar.tsx @@ -29,6 +29,7 @@ interface Props { onAddToQueue: (item: SonosItem, position: number) => void; dockedWidth?: number; onResizeWidth?: (width: number) => void; + onResizeWidthLive?: (width: number) => void; } export interface QueueSidebarHandle { @@ -36,8 +37,8 @@ export interface QueueSidebarHandle { } const MIN_DOCKED_WIDTH = 280; -const MAX_DOCKED_WIDTH = 700; -const MIN_ROUTES_WIDTH = 320; +const PLAYER_BAR_W = 800; // matches .inner width in PlayerBar.module.css +const PLAYER_BAR_PADDING = 64; // 32px breathing room each side export const QueueSidebar = forwardRef(function QueueSidebar( { @@ -58,6 +59,7 @@ export const QueueSidebar = forwardRef(function Queue onAddToQueue, dockedWidth, onResizeWidth, + onResizeWidthLive, }, ref ) { @@ -122,16 +124,16 @@ export const QueueSidebar = forwardRef(function Queue return () => clearTimeout(id); }, [isActive]); - // Track change while queue is open + // Track change while queue is visible useEffect(() => { - if (!open) return; + if (!isActive) return; const id = setTimeout(scrollToNowPlaying, 50); return () => clearTimeout(id); }, [currentQueueItemId]); // eslint-disable-line react-hooks/exhaustive-deps // Group switch β€” queue reloads async so use a longer delay useEffect(() => { - if (!open) return; + if (!isActive) return; const id = setTimeout(scrollToNowPlaying, 400); return () => clearTimeout(id); }, [groupName]); // eslint-disable-line react-hooks/exhaustive-deps @@ -160,11 +162,12 @@ export const QueueSidebar = forwardRef(function Queue handle.setPointerCapture(e.pointerId); document.documentElement.classList.add('resizingQueue'); - const max = () => Math.min(MAX_DOCKED_WIDTH, window.innerWidth - MIN_ROUTES_WIDTH); + const max = () => window.innerWidth - (PLAYER_BAR_W + PLAYER_BAR_PADDING); const onMove = (ev: PointerEvent) => { const next = Math.max(MIN_DOCKED_WIDTH, Math.min(max(), startWidth + (startX - ev.clientX))); setLiveWidth(next); + onResizeWidthLive?.(next); }; const onUp = () => { document.documentElement.classList.remove('resizingQueue'); @@ -283,6 +286,7 @@ export const QueueSidebar = forwardRef(function Queue
e.currentTarget.style.setProperty('--mouse-y', `${e.nativeEvent.offsetY}px`)} role="separator" aria-orientation="vertical" aria-label="Resize queue" @@ -290,7 +294,9 @@ export const QueueSidebar = forwardRef(function Queue )} {isDocked && (
- +
+ +
)}
@@ -325,7 +331,7 @@ export const QueueSidebar = forwardRef(function Queue
{ e.preventDefault(); e.dataTransfer.dropEffect = e.dataTransfer.types.includes('application/sonos-item-list') ? 'copy' : 'move'; diff --git a/renderer/src/components/queuedle/QueuedlePanel.tsx b/renderer/src/components/queuedle/QueuedlePanel.tsx index f4f805d..de84cb0 100644 --- a/renderer/src/components/queuedle/QueuedlePanel.tsx +++ b/renderer/src/components/queuedle/QueuedlePanel.tsx @@ -1,5 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; -import { useDailyGame, useSubmitGameScore, useGameLeaderboard, useGameDates, useMyScore, useGameStats } from '../../hooks/useDailyGame'; +import { + useDailyGame, + useSubmitGameScore, + useGameLeaderboard, + useGameDates, + useGameRankings, + useMyScore, + useGameStats, +} from '../../hooks/useDailyGame'; import { QueuedleIntro } from './QueuedleIntro'; import { QueuedleQuestionCard } from './QueuedleQuestionCard'; import { QueuedleBonusScreen } from './QueuedleBonusScreen'; @@ -8,9 +16,11 @@ import { QueuedleSummary } from './QueuedleSummary'; import { QueuedleCalendar } from './QueuedleCalendar'; import { ScoreDistribution } from './ScoreDistribution'; import { MyScores } from './MyScores'; +import { getGameRankIcon } from '../../lib/gameRankAssets'; import styles from '../../styles/Queuedle.module.css'; type Phase = 'intro' | 'main' | 'bonus' | 'bonus-results' | 'summary'; +type LeaderboardTab = 'today' | 'ranked'; function londonDateToday(): string { const parts = new Intl.DateTimeFormat('en-GB', { @@ -27,6 +37,7 @@ function londonDateToday(): string { export function QueuedlePanel() { const [selectedDate, setSelectedDate] = useState(null); + const [leaderboardTab, setLeaderboardTab] = useState('today'); const todayId = useMemo(() => londonDateToday(), []); const { data, isLoading, error } = useDailyGame(selectedDate ?? undefined); const submit = useSubmitGameScore(); @@ -42,6 +53,7 @@ export function QueuedlePanel() { const leaderboard = useGameLeaderboard(gameId ?? undefined); const gameDates = useGameDates(displayName ?? undefined); + const rankings = useGameRankings(displayName ?? undefined, leaderboardTab === 'ranked'); const myScore = useMyScore(gameId, displayName); const gameStats = useGameStats(gameId); const alreadyPlayed = useMemo(() => { @@ -50,7 +62,11 @@ export function QueuedlePanel() { return existing ?? null; }, [displayName, leaderboard.data]); - const localPlayed = useMemo<{ mainScore: number; bonusScore: number; guesses?: Array<'left' | 'right'> } | null>(() => { + const localPlayed = useMemo<{ + mainScore: number; + bonusScore: number; + guesses?: Array<'left' | 'right'>; + } | null>(() => { if (!gameId) return null; try { const raw = localStorage.getItem(`queuedle-played:${gameId}`); @@ -138,12 +154,14 @@ export function QueuedlePanel() { const next = pickedGameId === todayId ? null : pickedGameId; if (next === selectedDate) return; setSelectedDate(next); + setLeaderboardTab('today'); resetGameState(); } function handleBackToToday() { if (selectedDate === null) return; setSelectedDate(null); + setLeaderboardTab('today'); resetGameState(); } @@ -212,11 +230,116 @@ export function QueuedlePanel() { ); + function renderLeaderboardArea( + currentGame: GameDoc, + scoreForDistributions: { mainScore: number; bonusScore: number } | null, + showMyScores: boolean + ) { + const scores = leaderboard.data && 'scores' in leaderboard.data ? leaderboard.data.scores : []; + const rankedRows = rankings.data ?? []; + const todayTabClass = `${styles.leaderboardTab}${ + leaderboardTab === 'today' ? ' ' + styles.leaderboardTabActive : '' + }`; + const rankedTabClass = `${styles.leaderboardTab}${ + leaderboardTab === 'ranked' ? ' ' + styles.leaderboardTabActive : '' + }`; + + return ( +
+ {showMyScores && scoreForDistributions && ( + + )} +
+ + +
+ {leaderboardTab === 'today' && scores.length > 0 && ( + <> +

+ {selectedDate && selectedDate !== todayId ? `${selectedDate} Leaderboard` : "Today's Leaderboard"} +

+ {scores.slice(0, 10).map((s, i) => ( +
+ {i < 3 ? ['πŸ₯‡', 'πŸ₯ˆ', 'πŸ₯‰'][i] : i + 1} + {s.userName} + + {s.mainScore}/{currentGame.questions.length} Β· {s.bonusScore}/{currentGame.questions.length} + + {s.total} +
+ ))} + s.mainScore)} + maxScore={currentGame.questions.length} + playerScore={scoreForDistributions?.mainScore ?? -1} + title="Higher or Lower" + /> + s.bonusScore)} + maxScore={currentGame.questions.length} + playerScore={scoreForDistributions?.bonusScore ?? -1} + title="Top Queuer" + /> + + )} + {leaderboardTab === 'ranked' && ( + <> +

Ranked Leaderboard

+ {rankings.isLoading &&
Loading rankings...
} + {!rankings.isLoading && rankedRows.length === 0 && ( +
No ranked scores yet.
+ )} + {!rankings.isLoading && + rankedRows.slice(0, 10).map((ranked, i) => ( +
+ {i + 1} + {ranked.userName} + + {ranked.gamesPlayed} {ranked.gamesPlayed === 1 ? 'game' : 'games'} Β·{' '} + + {getGameRankIcon(ranked.tierKey) && ( + + )} + {ranked.tierName} + + + {ranked.averageTotal.toFixed(1)} +
+ ))} + + )} +
+ ); + } + if (showAlreadyPlayed) { const cachedScore = localPlayed ?? (alreadyPlayed ? { mainScore: alreadyPlayed.mainScore, bonusScore: alreadyPlayed.bonusScore } : null); - const scores = leaderboard.data && 'scores' in leaderboard.data ? leaderboard.data.scores : []; const calendarDates = gameDates.data?.dates ?? []; return (
@@ -251,45 +374,7 @@ export function QueuedlePanel() { onPlayAgain={handleDevPlayAgain} /> )} -
- {cachedScore && ( - - )} - {scores.length > 0 && ( - <> -

- {selectedDate && selectedDate !== todayId ? `${selectedDate} Leaderboard` : "Today's Leaderboard"} -

- {scores.slice(0, 10).map((s, i) => ( -
- {i < 3 ? ['πŸ₯‡', 'πŸ₯ˆ', 'πŸ₯‰'][i] : i + 1} - {s.userName} - - {s.mainScore}/{game.questions.length} Β· {s.bonusScore}/{game.questions.length} - - {s.total} -
- ))} - s.mainScore)} - maxScore={game.questions.length} - playerScore={cachedScore?.mainScore ?? -1} - title="Higher or Lower" - /> - s.bonusScore)} - maxScore={game.questions.length} - playerScore={cachedScore?.bonusScore ?? -1} - title="Top Queuer" - /> - - )} -
+ {renderLeaderboardArea(game, cachedScore, true)}
{calendarOpen && calendarDates.length > 0 && ( @@ -477,15 +562,18 @@ export function QueuedlePanel() { )} {phase === 'summary' && localScore && ( - +
+ + {renderLeaderboardArea(game, { mainScore: localScore.main, bonusScore: localScore.bonus }, false)} +
)}
diff --git a/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx b/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx index 01b0c52..5262ab7 100644 --- a/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx +++ b/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx @@ -10,6 +10,7 @@ const mockUseDailyGame = vi.fn(); const mockUseSubmitGameScore = vi.fn(); const mockUseGameLeaderboard = vi.fn(); const mockUseGameDates = vi.fn(); +const mockUseGameRankings = vi.fn(); const mockUseMyScore = vi.fn(); const mockUseGameStats = vi.fn(); vi.mock('@/hooks/useDailyGame', () => ({ @@ -17,6 +18,7 @@ vi.mock('@/hooks/useDailyGame', () => ({ useSubmitGameScore: () => mockUseSubmitGameScore(), useGameLeaderboard: (date?: string) => mockUseGameLeaderboard(date), useGameDates: (userName?: string | null) => mockUseGameDates(userName), + useGameRankings: (userName?: string | null, enabled?: boolean) => mockUseGameRankings(userName, enabled), useMyScore: (gameId: string | null, userName: string | null | undefined) => mockUseMyScore(gameId, userName), useGameStats: (gameId: string | null) => mockUseGameStats(gameId), })); @@ -149,6 +151,7 @@ beforeEach(() => { mockUseSubmitGameScore.mockReturnValue({ mutateAsync: vi.fn(), isPending: false }); mockUseGameLeaderboard.mockReturnValue({ data: { scores: [] }, isLoading: false }); mockUseGameDates.mockReturnValue({ data: { dates: [] }, isLoading: false }); + mockUseGameRankings.mockReturnValue({ data: [], isLoading: false }); mockUseMyScore.mockReturnValue({ data: undefined, isLoading: false }); mockUseGameStats.mockReturnValue({ data: undefined, isLoading: false }); }); @@ -280,6 +283,21 @@ describe('QueuedlePanel', () => { expect(screen.getByText('Score: 2')).toBeInTheDocument(); }); + it('shows ranked tab immediately after finishing the game', async () => { + const mutateAsync = vi.fn().mockResolvedValue({ score: { mainScore: 1, bonusScore: 1 } }); + mockUseSubmitGameScore.mockReturnValue({ mutateAsync, isPending: false }); + const user = userEvent.setup(); + render(); + await startGame(user); + await user.click(screen.getByText('Pick Left')); + await user.click(screen.getByText('Next')); + await user.click(screen.getByText('Submit Bonus')); + await waitFor(() => screen.getByText('Bonus results')); + await user.click(screen.getByText('See Score')); + expect(screen.getByText('Score: 2')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Ranked' })).toBeInTheDocument(); + }); + it('uses result.existing score when no result.score', async () => { const mutateAsync = vi.fn().mockResolvedValue({ existing: { mainScore: 0, bonusScore: 1 } }); mockUseSubmitGameScore.mockReturnValue({ mutateAsync, isPending: false }); @@ -384,6 +402,80 @@ describe('QueuedlePanel', () => { expect(screen.getByText('alice')).toBeInTheDocument(); }); + it('shows ranked tab after the user has played', async () => { + localStorage.setItem('queuedle-played:2024-01-01', JSON.stringify({ mainScore: 2, bonusScore: 1 })); + render(); + await waitFor(() => expect(screen.getByRole('tab', { name: 'Ranked' })).toBeInTheDocument()); + await waitFor(() => expect(mockUseGameRankings).toHaveBeenLastCalledWith('TestUser', false)); + }); + + it('clicking Ranked shows average total rankings', async () => { + localStorage.setItem('queuedle-played:2024-01-01', JSON.stringify({ mainScore: 2, bonusScore: 1 })); + mockUseGameRankings.mockReturnValue({ + data: [ + { + userName: 'alice', + gamesPlayed: 2, + averageTotal: 4.5, + averageMain: 3, + averageBonus: 1.5, + averagePercent: 75, + bestTotal: 5, + tierKey: 'provisional', + tierName: 'Provisional', + isProvisional: true, + }, + ], + isLoading: false, + }); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('tab', { name: 'Ranked' })); + expect(screen.getByText('Ranked Leaderboard')).toBeInTheDocument(); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('Provisional')).toBeInTheDocument(); + expect(screen.getByText('4.5')).toBeInTheDocument(); + await waitFor(() => expect(mockUseGameRankings).toHaveBeenLastCalledWith('TestUser', true)); + }); + + it('clicking Today restores the daily leaderboard', async () => { + localStorage.setItem('queuedle-played:2024-01-01', JSON.stringify({ mainScore: 2, bonusScore: 1 })); + mockUseGameLeaderboard.mockReturnValue({ + data: { + scores: [ + { userName: 'TestUser', mainScore: 2, bonusScore: 1, total: 3 }, + { userName: 'daily-player', mainScore: 1, bonusScore: 1, total: 2 }, + ], + }, + isLoading: false, + }); + mockUseGameRankings.mockReturnValue({ + data: [ + { + userName: 'ranked-player', + gamesPlayed: 2, + averageTotal: 4.5, + averageMain: 3, + averageBonus: 1.5, + averagePercent: 75, + bestTotal: 5, + tierKey: 'algorithm-whisperer', + tierName: 'Algorithm Whisperer', + isProvisional: false, + }, + ], + isLoading: false, + }); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('tab', { name: 'Ranked' })); + expect(screen.getByText('ranked-player')).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: 'Today' })); + expect(screen.getByText("Today's Leaderboard")).toBeInTheDocument(); + expect(screen.getByText('daily-player')).toBeInTheDocument(); + expect(screen.queryByText('ranked-player')).not.toBeInTheDocument(); + }); + it('skips the intro and shows already-played when localStorage has the score', async () => { localStorage.setItem('queuedle-played:2024-01-01', JSON.stringify({ mainScore: 2, bonusScore: 1 })); // Leaderboard hasn't loaded yet β€” localStorage should short-circuit. diff --git a/renderer/src/hooks/__tests__/useDailyGame.test.ts b/renderer/src/hooks/__tests__/useDailyGame.test.ts index 8c01101..749a823 100644 --- a/renderer/src/hooks/__tests__/useDailyGame.test.ts +++ b/renderer/src/hooks/__tests__/useDailyGame.test.ts @@ -2,12 +2,41 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createElement } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor, act } from '@testing-library/react'; -import { dailyGameQueryOptions, useDailyGame, useGameLeaderboard, useSubmitGameScore } from '../useDailyGame'; +import { + calculateGameRankings, + dailyGameQueryOptions, + getGameRankTier, + useDailyGame, + useGameLeaderboard, + useGameRankings, + useSubmitGameScore, +} from '../useDailyGame'; const mockFetch = vi.mocked(window.sonos.fetchDailyGame); +function rankingSource(scores: unknown[], maxTotal = 10) { + return { + maxTotal, + leaderboard: { scores }, + }; +} + +function gameWithQuestionCount(id: string, questionCount: number): GameDoc { + return { + id, + status: 'ready', + generatedAt: 1, + lowData: false, + questions: new Array(questionCount).fill(null) as unknown as GameQuestion[], + }; +} + function wrapper({ children }: { children: React.ReactNode }) { - return createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); + return createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children + ); } beforeEach(() => { @@ -49,7 +78,9 @@ describe('dailyGameQueryOptions', () => { it('refetchInterval returns false when data is a ready game', () => { const opts = dailyGameQueryOptions(); - const interval = opts.refetchInterval({ state: { data: { id: 'g1', questions: [] } as unknown as GameFetchResult } }); + const interval = opts.refetchInterval({ + state: { data: { id: 'g1', questions: [] } as unknown as GameFetchResult }, + }); expect(interval).toBe(false); }); @@ -78,9 +109,173 @@ describe('useGameLeaderboard', () => { }); }); +describe('calculateGameRankings', () => { + it('averages multiple games and includes one-game players', () => { + const rankings = calculateGameRankings([ + rankingSource( + [ + { userName: 'alice', mainScore: 3, bonusScore: 1, total: 4 }, + { userName: 'bob', mainScore: 2, bonusScore: 2, total: 4 }, + ], + 6 + ), + rankingSource([{ userName: 'alice', mainScore: 5, bonusScore: 1, total: 6 }], 6), + ]); + + expect(rankings[0]).toMatchObject({ + userName: 'alice', + gamesPlayed: 2, + averageTotal: 5, + averageMain: 4, + averageBonus: 1, + averagePercent: (10 / 12) * 100, + bestTotal: 6, + tierName: 'Provisional', + isProvisional: true, + }); + expect(rankings[1]).toMatchObject({ + userName: 'bob', + gamesPlayed: 1, + averageTotal: 4, + averageMain: 2, + averageBonus: 2, + averagePercent: (4 / 6) * 100, + bestTotal: 4, + tierName: 'Provisional', + isProvisional: true, + }); + }); + + it('applies tie-breakers deterministically', () => { + const rankings = calculateGameRankings([ + rankingSource([ + { userName: 'alice', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'bob', mainScore: 4, bonusScore: 0, total: 4 }, + { userName: 'carl', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'zoe', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'anna', mainScore: 5, bonusScore: 0, total: 5 }, + ]), + rankingSource([ + { userName: 'alice', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'bob', mainScore: 6, bonusScore: 0, total: 6 }, + { userName: 'carl', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'zoe', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'anna', mainScore: 5, bonusScore: 0, total: 5 }, + ]), + rankingSource([{ userName: 'carl', mainScore: 5, bonusScore: 0, total: 5 }]), + ]); + + expect(rankings.map((ranking) => ranking.userName)).toEqual(['carl', 'bob', 'alice', 'anna', 'zoe']); + }); + + it('skips missing and invalid leaderboard responses safely', () => { + const rankings = calculateGameRankings([ + { + maxTotal: null, + leaderboard: { scores: [{ userName: 'skipped', mainScore: 2, bonusScore: 1, total: 3 }] }, + }, + { maxTotal: 6, leaderboard: null }, + { maxTotal: 6, leaderboard: { error: 'failed' } }, + { maxTotal: 6, leaderboard: { scores: 'not scores' } }, + rankingSource( + [ + { userName: '', mainScore: 2, bonusScore: 1, total: 3 }, + { userName: 'alice', mainScore: 2, bonusScore: 1, total: 3 }, + ], + 6 + ), + ]); + + expect(rankings).toHaveLength(1); + expect(rankings[0]).toMatchObject({ userName: 'alice', gamesPlayed: 1, averageTotal: 3 }); + }); + + it('assigns percentage tiers at threshold boundaries', () => { + expect(getGameRankTier(39.9, 3).name).toBe('Skip Button Survivor'); + expect(getGameRankTier(40, 3).name).toBe('Background Bopper'); + expect(getGameRankTier(55, 3).name).toBe('Aux Cable Apprentice'); + expect(getGameRankTier(70, 3).name).toBe('Algorithm Whisperer'); + expect(getGameRankTier(85, 3).name).toBe('Playlist Prophet'); + }); + + it('marks players with fewer than three games as provisional', () => { + expect(getGameRankTier(100, 2)).toEqual({ + key: 'provisional', + name: 'Provisional', + isProvisional: true, + }); + }); +}); + +describe('useGameRankings', () => { + it('fetches ready game leaderboards and ranks the results', async () => { + vi.mocked(window.sonos.fetchGameDates).mockResolvedValue({ + dates: [ + { gameId: '2026-04-21', status: 'ready', userPlayed: true }, + { gameId: '2026-04-22', status: 'generating', userPlayed: false }, + { gameId: '2026-04-23', status: 'ready', userPlayed: false }, + ], + }); + vi.mocked(window.sonos.fetchGameLeaderboard).mockImplementation(async (date?: string) => { + if (date === '2026-04-21') { + return { + gameId: date, + scores: [{ userName: 'alice', mainScore: 3, bonusScore: 1, total: 4 }], + } as GameLeaderboardResult; + } + return { + gameId: date ?? '', + scores: [{ userName: 'alice', mainScore: 5, bonusScore: 1, total: 6 }], + } as GameLeaderboardResult; + }); + mockFetch.mockImplementation(async (date?: string) => gameWithQuestionCount(date ?? '', 3)); + + const { result } = renderHook(() => useGameRankings('alice', true), { wrapper }); + + await waitFor(() => expect(result.current.data?.[0]?.averageTotal).toBe(5)); + expect(result.current.data?.[0]?.averagePercent).toBeCloseTo((10 / 12) * 100); + expect(window.sonos.fetchGameDates).toHaveBeenCalledWith('alice'); + expect(window.sonos.fetchGameLeaderboard).toHaveBeenCalledTimes(2); + expect(window.sonos.fetchGameLeaderboard).toHaveBeenCalledWith('2026-04-21'); + expect(window.sonos.fetchGameLeaderboard).toHaveBeenCalledWith('2026-04-23'); + expect(window.sonos.fetchDailyGame).toHaveBeenCalledWith('2026-04-21'); + expect(window.sonos.fetchDailyGame).toHaveBeenCalledWith('2026-04-23'); + }); + + it('skips missing game docs while ranking', async () => { + vi.mocked(window.sonos.fetchGameDates).mockResolvedValue({ + dates: [ + { gameId: 'ready-game', status: 'ready', userPlayed: true }, + { gameId: 'missing-game', status: 'ready', userPlayed: true }, + ], + }); + vi.mocked(window.sonos.fetchGameLeaderboard).mockResolvedValue({ + gameId: 'any', + scores: [{ userName: 'alice', mainScore: 3, bonusScore: 1, total: 4 }], + } as GameLeaderboardResult); + mockFetch.mockImplementation(async (date?: string) => { + if (date === 'missing-game') return { error: 'missing' }; + return gameWithQuestionCount(date ?? '', 2); + }); + + const { result } = renderHook(() => useGameRankings('alice', true), { wrapper }); + + await waitFor(() => expect(result.current.data?.[0]?.gamesPlayed).toBe(1)); + expect(result.current.data?.[0]?.averagePercent).toBe(100); + }); + + it('does not fetch while disabled', () => { + renderHook(() => useGameRankings('alice', false), { wrapper }); + expect(window.sonos.fetchGameDates).not.toHaveBeenCalled(); + expect(window.sonos.fetchGameLeaderboard).not.toHaveBeenCalled(); + }); +}); + describe('useSubmitGameScore', () => { it('calls submitGameScore on mutate', async () => { - vi.mocked(window.sonos.submitGameScore).mockResolvedValue({ score: { mainScore: 3, bonusScore: 2, total: 5 } } as GameSubmitResult); + vi.mocked(window.sonos.submitGameScore).mockResolvedValue({ + score: { mainScore: 3, bonusScore: 2, total: 5 }, + } as GameSubmitResult); const { result } = renderHook(() => useSubmitGameScore(), { wrapper }); await act(async () => { await result.current.mutateAsync({ diff --git a/renderer/src/hooks/useDailyGame.ts b/renderer/src/hooks/useDailyGame.ts index 5ecac2f..fc856a4 100644 --- a/renderer/src/hooks/useDailyGame.ts +++ b/renderer/src/hooks/useDailyGame.ts @@ -1,5 +1,144 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +export interface GameRanking { + userName: string; + gamesPlayed: number; + averageTotal: number; + averageMain: number; + averageBonus: number; + averagePercent: number; + bestTotal: number; + tierKey: GameRankTierKey; + tierName: string; + isProvisional: boolean; +} + +interface RankingAccumulator { + userName: string; + gamesPlayed: number; + totalScore: number; + possibleScore: number; + mainScore: number; + bonusScore: number; + bestTotal: number; +} + +export type GameRankTierKey = + | 'provisional' + | 'skip-button-survivor' + | 'background-bopper' + | 'aux-cable-apprentice' + | 'algorithm-whisperer' + | 'playlist-prophet'; + +interface GameRankTier { + key: GameRankTierKey; + name: string; +} + +interface GameRankingSource { + leaderboard: unknown; + maxTotal: number | null; +} + +const MIN_TIER_GAMES = 3; + +const PROVISIONAL_TIER: GameRankTier = { + key: 'provisional', + name: 'Provisional', +}; + +export function getGameRankTier( + averagePercent: number, + gamesPlayed: number +): GameRankTier & { isProvisional: boolean } { + if (gamesPlayed < MIN_TIER_GAMES) { + return { ...PROVISIONAL_TIER, isProvisional: true }; + } + if (averagePercent >= 85) return { key: 'playlist-prophet', name: 'Playlist Prophet', isProvisional: false }; + if (averagePercent >= 70) return { key: 'algorithm-whisperer', name: 'Algorithm Whisperer', isProvisional: false }; + if (averagePercent >= 55) return { key: 'aux-cable-apprentice', name: 'Aux Cable Apprentice', isProvisional: false }; + if (averagePercent >= 40) return { key: 'background-bopper', name: 'Background Bopper', isProvisional: false }; + return { key: 'skip-button-survivor', name: 'Skip Button Survivor', isProvisional: false }; +} + +function isReadyGameDoc(value: unknown): value is GameDoc { + return !!value && typeof value === 'object' && 'questions' in value && Array.isArray((value as GameDoc).questions); +} + +function isScoreRow(value: unknown): value is Pick { + if (!value || typeof value !== 'object') return false; + const row = value as Partial; + return ( + typeof row.userName === 'string' && + row.userName.trim().length > 0 && + typeof row.mainScore === 'number' && + typeof row.bonusScore === 'number' && + typeof row.total === 'number' + ); +} + +export function calculateGameRankings(sources: GameRankingSource[]): GameRanking[] { + const byUser = new Map(); + + for (const source of sources) { + const { leaderboard, maxTotal } = source; + if (typeof maxTotal !== 'number' || maxTotal <= 0) continue; + if (!leaderboard || typeof leaderboard !== 'object') continue; + const scores = (leaderboard as Partial).scores; + if (!Array.isArray(scores)) continue; + + for (const score of scores) { + if (!isScoreRow(score)) continue; + const userName = score.userName.trim(); + const existing = + byUser.get(userName) ?? + ({ + userName, + gamesPlayed: 0, + totalScore: 0, + possibleScore: 0, + mainScore: 0, + bonusScore: 0, + bestTotal: Number.NEGATIVE_INFINITY, + } satisfies RankingAccumulator); + + existing.gamesPlayed += 1; + existing.totalScore += score.total; + existing.possibleScore += maxTotal; + existing.mainScore += score.mainScore; + existing.bonusScore += score.bonusScore; + existing.bestTotal = Math.max(existing.bestTotal, score.total); + byUser.set(userName, existing); + } + } + + return [...byUser.values()] + .map((entry) => { + const averagePercent = (entry.totalScore / entry.possibleScore) * 100; + const tier = getGameRankTier(averagePercent, entry.gamesPlayed); + return { + userName: entry.userName, + gamesPlayed: entry.gamesPlayed, + averageTotal: entry.totalScore / entry.gamesPlayed, + averageMain: entry.mainScore / entry.gamesPlayed, + averageBonus: entry.bonusScore / entry.gamesPlayed, + averagePercent, + bestTotal: entry.bestTotal, + tierKey: tier.key, + tierName: tier.name, + isProvisional: tier.isProvisional, + }; + }) + .sort( + (a, b) => + b.averageTotal - a.averageTotal || + b.gamesPlayed - a.gamesPlayed || + b.bestTotal - a.bestTotal || + a.userName.localeCompare(b.userName) + ); +} + export function dailyGameQueryOptions(date?: string) { return { queryKey: ['queuedle', date ?? 'today'] as const, @@ -40,6 +179,37 @@ export function useGameDates(userName: string | null | undefined) { }); } +export function useGameRankings(userName: string | null | undefined, enabled: boolean) { + return useQuery({ + queryKey: ['queuedle-rankings', userName ?? ''], + queryFn: async () => { + const datesResult = await window.sonos.fetchGameDates(userName ?? ''); + const dates = Array.isArray(datesResult.dates) ? datesResult.dates.filter((date) => date.status === 'ready') : []; + const sources: Array = await Promise.all( + dates.map(async (date) => { + try { + const [leaderboard, game] = await Promise.all([ + window.sonos.fetchGameLeaderboard(date.gameId), + window.sonos.fetchDailyGame(date.gameId), + ]); + if (!isReadyGameDoc(game)) return null; + return { + leaderboard, + maxTotal: game.questions.length * 2, + } satisfies GameRankingSource; + } catch { + return null; + } + }) + ); + return calculateGameRankings(sources.filter((source): source is GameRankingSource => source !== null)); + }, + enabled: enabled && userName !== undefined, + staleTime: 5 * 60_000, + refetchInterval: 5 * 60_000, + }); +} + export function useGameStats(gameId: string | null) { return useQuery({ queryKey: ['queuedle-stats', gameId ?? 'today'], @@ -76,6 +246,7 @@ export function useSubmitGameScore() { qc.invalidateQueries({ queryKey: ['queuedle', variables.gameId] }); qc.invalidateQueries({ queryKey: ['queuedle', 'today'] }); qc.invalidateQueries({ queryKey: ['queuedle-dates'] }); + qc.invalidateQueries({ queryKey: ['queuedle-rankings'] }); }, }); } diff --git a/renderer/src/hooks/useDominantColor.ts b/renderer/src/hooks/useDominantColor.ts index 5b1785c..16c7931 100644 --- a/renderer/src/hooks/useDominantColor.ts +++ b/renderer/src/hooks/useDominantColor.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -export function useDominantColor(src: string | null): string | null { +export function useDominantColor(src: string | null, { setGlobal = false } = {}): string | null { const [color, setColor] = useState(null); useEffect(() => { @@ -38,5 +38,15 @@ export function useDominantColor(src: string | null): string | null { img.src = src; }, [src]); + useEffect(() => { + if (!setGlobal) return; + if (color) { + document.documentElement.style.setProperty('--panel-color', color); + } else { + document.documentElement.style.removeProperty('--panel-color'); + } + return () => { document.documentElement.style.removeProperty('--panel-color'); }; + }, [color, setGlobal]); + return color; } diff --git a/renderer/src/lib/gameRankAssets.ts b/renderer/src/lib/gameRankAssets.ts new file mode 100644 index 0000000..bed116c --- /dev/null +++ b/renderer/src/lib/gameRankAssets.ts @@ -0,0 +1,36 @@ +import type { GameRankTierKey } from '../hooks/useDailyGame'; + +import skipButtonSurvivor from '../../../assets/skipbuttonsurvivor-notext.png'; +import skipButtonSurvivorText from '../../../assets/Skip Button Survivor.png'; +import backgroundBopper from '../../../assets/backgroundbooper-notext.png'; +import backgroundBopperText from '../../../assets/BackgroundBopper.png'; +import auxCableApprentice from '../../../assets/auxcableapprentice-notext.png'; +import auxCableApprenticeText from '../../../assets/Aux Cable Apprentice.png'; +import algorithmWhisperer from '../../../assets/AlgorithWhiperer-notext.png'; +import algorithmWhispererText from '../../../assets/AlgorithmWhisperer.png'; +import playlistProphet from '../../../assets/playlistprophet-notext.png'; +import playlistProphetText from '../../../assets/PlaylistProphet.png'; + +const rankIcons: Partial> = { + 'skip-button-survivor': skipButtonSurvivor, + 'background-bopper': backgroundBopper, + 'aux-cable-apprentice': auxCableApprentice, + 'algorithm-whisperer': algorithmWhisperer, + 'playlist-prophet': playlistProphet, +}; + +const rankInfoImages: Partial> = { + 'skip-button-survivor': skipButtonSurvivorText, + 'background-bopper': backgroundBopperText, + 'aux-cable-apprentice': auxCableApprenticeText, + 'algorithm-whisperer': algorithmWhispererText, + 'playlist-prophet': playlistProphetText, +}; + +export function getGameRankIcon(tierKey: GameRankTierKey) { + return rankIcons[tierKey] ?? null; +} + +export function getGameRankInfoImage(tierKey: GameRankTierKey) { + return rankInfoImages[tierKey] ?? null; +} diff --git a/renderer/src/styles/App.module.css b/renderer/src/styles/App.module.css index 7c48b1b..1d48ed1 100644 --- a/renderer/src/styles/App.module.css +++ b/renderer/src/styles/App.module.css @@ -4,9 +4,9 @@ flex-direction: column; overflow: hidden; background: - radial-gradient(ellipse at 20% 50%, rgba(70, 45, 110, 0.45) 0%, transparent 60%), - radial-gradient(ellipse at 80% 15%, rgba(25, 55, 95, 0.35) 0%, transparent 55%), - radial-gradient(ellipse at 55% 85%, rgba(20, 55, 85, 0.3) 0%, transparent 50%), + radial-gradient(ellipse at 20% 50%, rgba(90, 50, 150, 0.7) 0%, transparent 60%), + radial-gradient(ellipse at 80% 15%, rgba(25, 70, 130, 0.55) 0%, transparent 55%), + radial-gradient(ellipse at 55% 85%, rgba(20, 65, 110, 0.45) 0%, transparent 50%), #0f0f14; } @@ -15,6 +15,14 @@ min-height: 0; overflow: hidden; display: flex; + position: relative; + background: linear-gradient( + 180deg, + rgba(var(--panel-color, 80, 60, 120), 0.55) 0%, + rgba(var(--panel-color, 80, 60, 120), 0.2) 20%, + transparent 40% + ); + transition: background 0.8s ease; } .toast { diff --git a/renderer/src/styles/LeaderboardPanel.module.css b/renderer/src/styles/LeaderboardPanel.module.css index 69daf37..1c21c97 100644 --- a/renderer/src/styles/LeaderboardPanel.module.css +++ b/renderer/src/styles/LeaderboardPanel.module.css @@ -28,10 +28,15 @@ cursor: pointer; padding: 4px 8px; border-radius: var(--r-sm); - transition: color 0.15s, background 0.15s; + transition: + color 0.15s, + background 0.15s; line-height: 1; } -.backBtn:hover { color: var(--text-2); background: var(--bg-2); } +.backBtn:hover { + color: var(--text-2); + background: var(--bg-2); +} .title { font-size: 20px; @@ -58,11 +63,18 @@ padding: 5px 14px; border-radius: 16px; cursor: pointer; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; white-space: nowrap; } -.periodBtn:hover { color: var(--text-2); } -.periodBtn.active { background: rgba(255, 255, 255, 0.12); color: var(--text); } +.periodBtn:hover { + color: var(--text-2); +} +.periodBtn.active { + background: rgba(255, 255, 255, 0.12); + color: var(--text); +} .refreshBtn { background: none; @@ -72,11 +84,36 @@ cursor: pointer; padding: 5px 8px; border-radius: var(--r-sm); - transition: color 0.15s, background 0.15s; + transition: + color 0.15s, + background 0.15s; line-height: 1; margin-left: auto; } -.refreshBtn:hover { color: var(--text-2); background: var(--bg-2); } +.refreshBtn:hover { + color: var(--text-2); + background: var(--bg-2); +} + +.infoBtn { + background: none; + border: none; + color: var(--text-3); + cursor: pointer; + width: 30px; + height: 30px; + border-radius: var(--r-sm); + transition: + color 0.15s, + background 0.15s; + display: inline-flex; + align-items: center; + justify-content: center; +} +.infoBtn:hover { + color: var(--text-2); + background: var(--bg-2); +} .state { padding: 48px 28px; @@ -92,6 +129,18 @@ display: flex; flex-direction: column; gap: 28px; + animation: leaderboardBodyIn 0.18s ease-out; +} + +@keyframes leaderboardBodyIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } } .section { @@ -125,7 +174,9 @@ padding: 0 2px; line-height: 1; } -.collapseBtn:hover { color: var(--text); } +.collapseBtn:hover { + color: var(--text); +} .empty { font-size: 13px; @@ -147,7 +198,80 @@ padding: 6px 4px; transition: background 0.12s; } -.userRowClickable:hover { background: rgba(255, 255, 255, 0.05); } +.userRowClickable:hover { + background: rgba(255, 255, 255, 0.05); +} + +.queuedleRow { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 4px; +} + +.queuedleGames { + font-size: 11px; + color: var(--text-3); + font-variant-numeric: tabular-nums; + width: 58px; + text-align: right; + flex-shrink: 0; + white-space: nowrap; +} + +.rankTier { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + color: var(--text); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + padding: 3px 10px 3px 6px; + width: 156px; + text-align: center; + flex-shrink: 0; + white-space: nowrap; + transition: background 0.15s, border-color 0.15s; +} + +.rankTierIcon { + width: 22px; + height: 22px; + object-fit: contain; + flex-shrink: 0; + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.35)); +} + +/* ── Tier colour accents for leaderboard rank pills ── */ +.rankTier[data-tier="skip-button-survivor"] { + border-color: rgba(100, 149, 237, 0.22); + background: rgba(100, 149, 237, 0.08); +} +.rankTier[data-tier="background-bopper"] { + border-color: rgba(205, 127, 50, 0.22); + background: rgba(205, 127, 50, 0.08); +} +.rankTier[data-tier="aux-cable-apprentice"] { + border-color: rgba(0, 210, 211, 0.22); + background: rgba(0, 210, 211, 0.08); +} +.rankTier[data-tier="algorithm-whisperer"] { + border-color: rgba(168, 85, 247, 0.22); + background: rgba(168, 85, 247, 0.08); +} +.rankTier[data-tier="playlist-prophet"] { + border-color: rgba(234, 179, 8, 0.22); + background: rgba(234, 179, 8, 0.08); +} + +.rankTierProvisional { + color: var(--text-3); + background: rgba(255, 255, 255, 0.04); +} .rank { width: 28px; @@ -214,7 +338,9 @@ padding: 5px 0; cursor: grab; } -.trackRow:active { cursor: grabbing; } +.trackRow:active { + cursor: grabbing; +} .art { width: 36px; @@ -269,7 +395,9 @@ cursor: pointer; transition: color 0.12s; } -.artistLink:hover { color: var(--text-2); } +.artistLink:hover { + color: var(--text-2); +} /* ── Artist row ── */ .artistRow { @@ -288,7 +416,9 @@ flex-shrink: 0; background: var(--bg-2); } -.artistArtPh { background: rgba(255, 255, 255, 0.06); } +.artistArtPh { + background: rgba(255, 255, 255, 0.06); +} .artistName { flex: 1; @@ -311,7 +441,9 @@ text-overflow: ellipsis; white-space: nowrap; } -.artistRow .artistLink:hover { color: var(--text-2); } +.artistRow .artistLink:hover { + color: var(--text-2); +} /* ── Footer ── */ .footer { @@ -321,6 +453,197 @@ border-top: 1px solid var(--border); } +.rankInfoOverlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(24px) saturate(140%); + display: flex; + align-items: center; + justify-content: center; +} + +.rankInfoDialog { + width: 100%; + height: 100%; + background: radial-gradient(ellipse at 30% 40%, rgba(70, 45, 110, 0.3) 0%, transparent 60%), + radial-gradient(ellipse at 70% 20%, rgba(25, 55, 95, 0.25) 0%, transparent 55%), + rgba(10, 10, 14, 0.92); + backdrop-filter: blur(48px) saturate(200%) brightness(1.03); + will-change: backdrop-filter; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 48px; + gap: 28px; + animation: rankInfoIn 0.28s ease-out; + overflow-y: auto; +} + +@keyframes rankInfoIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.rankInfoHeader { + display: flex; + align-items: center; + gap: 16px; + position: relative; +} + +.rankInfoTitle { + margin: 0; + color: var(--text); + font-size: 28px; + font-weight: 700; + letter-spacing: -0.4px; + text-align: center; +} + +.rankInfoClose { + position: fixed; + top: 18px; + right: 18px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-2); + cursor: pointer; + width: 36px; + height: 36px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + z-index: 1; + transition: background 0.15s, color 0.15s; +} +.rankInfoClose:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.14); +} + +.rankInfoText { + margin: 0; + color: var(--text-3); + font-size: 13px; + line-height: 1.45; + text-align: center; + max-width: 480px; +} + +.rankInfoList { + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: center; + gap: 20px; + width: 80%; + flex-wrap: wrap; +} + +.rankInfoRow { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 24px 12px 22px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--r-lg); + color: var(--text); + flex: 1 1 0; + transition: background 0.2s, border-color 0.2s, transform 0.2s; +} + +.rankInfoRow:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.12); + transform: translateY(-4px); +} + +.rankInfoName { + font-size: 14px; + font-weight: 600; + color: var(--text); + text-align: center; + line-height: 1.3; +} + +.rankInfoRange { + font-size: 13px; + color: var(--text-3); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.rankInfoImage { + width: 80%; + aspect-ratio: 1; + object-fit: contain; + filter: drop-shadow(0 4px 24px rgba(0, 0, 0, 0.5)); + transition: transform 0.25s ease; +} + +.rankInfoRow:hover .rankInfoImage { + transform: scale(1.08); +} + +.rankInfoFooter { + font-size: 12px; + color: var(--text-3); + text-align: center; + margin-top: 4px; +} + +/* ── Tier colour accents for rank info cards ── */ +.rankInfoRow[data-tier="skip-button-survivor"] { + border-color: rgba(100, 149, 237, 0.18); + background: rgba(100, 149, 237, 0.04); +} +.rankInfoRow[data-tier="skip-button-survivor"]:hover { + border-color: rgba(100, 149, 237, 0.3); + background: rgba(100, 149, 237, 0.08); +} +.rankInfoRow[data-tier="background-bopper"] { + border-color: rgba(205, 127, 50, 0.18); + background: rgba(205, 127, 50, 0.04); +} +.rankInfoRow[data-tier="background-bopper"]:hover { + border-color: rgba(205, 127, 50, 0.3); + background: rgba(205, 127, 50, 0.08); +} +.rankInfoRow[data-tier="aux-cable-apprentice"] { + border-color: rgba(0, 210, 211, 0.18); + background: rgba(0, 210, 211, 0.04); +} +.rankInfoRow[data-tier="aux-cable-apprentice"]:hover { + border-color: rgba(0, 210, 211, 0.3); + background: rgba(0, 210, 211, 0.08); +} +.rankInfoRow[data-tier="algorithm-whisperer"] { + border-color: rgba(168, 85, 247, 0.18); + background: rgba(168, 85, 247, 0.04); +} +.rankInfoRow[data-tier="algorithm-whisperer"]:hover { + border-color: rgba(168, 85, 247, 0.3); + background: rgba(168, 85, 247, 0.08); +} +.rankInfoRow[data-tier="playlist-prophet"] { + border-color: rgba(234, 179, 8, 0.18); + background: rgba(234, 179, 8, 0.04); +} +.rankInfoRow[data-tier="playlist-prophet"]:hover { + border-color: rgba(234, 179, 8, 0.3); + background: rgba(234, 179, 8, 0.08); +} + @media (max-width: 900px) { .columns { grid-template-columns: 1fr 1fr; diff --git a/renderer/src/styles/PlayerBar.module.css b/renderer/src/styles/PlayerBar.module.css index 710077f..1b2d606 100644 --- a/renderer/src/styles/PlayerBar.module.css +++ b/renderer/src/styles/PlayerBar.module.css @@ -2,7 +2,7 @@ .bar { position: fixed; bottom: 24px; - left: 50%; + left: calc((100vw - var(--docked-queue-w, 0px)) / 2); transform: translateX(-50%) translateZ(0); z-index: 150; border-radius: 22px; diff --git a/renderer/src/styles/QueueSidebar.module.css b/renderer/src/styles/QueueSidebar.module.css index dbb0068..1b7f72a 100644 --- a/renderer/src/styles/QueueSidebar.module.css +++ b/renderer/src/styles/QueueSidebar.module.css @@ -42,12 +42,12 @@ margin-left: auto; border-radius: 0; border: none; - border-left: 1px solid var(--border); box-shadow: none; backdrop-filter: none; - background: var(--bg-1); + background: transparent; transform: none; transition: none; + overflow: visible; /* Sit above the TopNav drag region (z-index 199) so the buttons below are clickable; the dockedTopBar provides its own drag region. */ z-index: 200; @@ -60,22 +60,50 @@ align-items: center; justify-content: flex-end; height: var(--nav-h); - padding: 0 8px; + padding: 0 12px; flex-shrink: 0; -webkit-app-region: drag; } +.dockedTopBar .winPill { + display: flex; + align-items: center; + gap: 2px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; + padding: 3px 4px; + -webkit-app-region: no-drag; +} + .resizeHandle { position: absolute; top: 0; left: 0; bottom: 0; - width: 6px; + width: 16px; cursor: ew-resize; background: transparent; z-index: 2; touch-action: none; -webkit-app-region: no-drag; } -.resizeHandle:hover { background: rgba(255, 255, 255, 0.08); } +.resizeHandle::after { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 280px; + background: radial-gradient( + ellipse 100% 300px at 0px var(--mouse-y, 50%), + rgba(200, 160, 255, 0.18) 0%, + rgba(160, 110, 255, 0.08) 50%, + transparent 80% + ); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} +.resizeHandle:hover::after { opacity: 1; } :global(html.resizingQueue), :global(html.resizingQueue) * { @@ -93,6 +121,14 @@ flex-shrink: 0; } +.sidebar.docked .header { + order: 99; + border-bottom: none; + border-top: 1px solid var(--border); + padding: 12px 16px; +} + + .title { font-size: 14px; font-weight: 600; @@ -161,6 +197,26 @@ padding: 6px 6px 24px; } +.content::-webkit-scrollbar { + width: 10px; +} +.content::-webkit-scrollbar-track { + background: transparent; + margin-top: 20px; /* never touch top */ + margin-bottom: 8px; +} +.content::-webkit-scrollbar-thumb { + /* transparent border shrinks visual width while keeping 16px hit box */ + border: 2px solid transparent; + background-clip: padding-box; + background-color: rgba(255, 255, 255, 0.18); + border-radius: 99px; + max-height: 300px; +} +.content::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.32); +} + /* ── Track row ── */ .row { display: flex; @@ -170,8 +226,25 @@ border-radius: var(--r-sm); transition: background 0.1s; cursor: default; + position: relative; + overflow: hidden; +} +.row::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + ellipse 60% 120% at var(--mx, 50%) var(--my, 50%), + rgba(190, 150, 255, 0.13) 0%, + rgba(150, 100, 255, 0.05) 50%, + transparent 75% + ); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; } -.row:hover { background: var(--bg-2); } +.row:hover::before { opacity: 1; } +.row:hover { background: rgba(255, 255, 255, 0.03); } .row.selected { background: rgba(255,255,255,0.1); } .row.selected:hover { background: rgba(255,255,255,0.13); } .row.playing .name { color: #fff; font-weight: 600; } @@ -241,11 +314,13 @@ .subAlbum { display: block; + width: fit-content; + max-width: 100%; background: none; border: none; padding: 0; font-size: 11px; font-family: inherit; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - width: 100%; text-align: left; + text-align: left; cursor: pointer; transition: color 0.12s; } .subAlbum:hover { color: var(--text); text-decoration: underline; } diff --git a/renderer/src/styles/Queuedle.module.css b/renderer/src/styles/Queuedle.module.css index d31194d..8d0aa99 100644 --- a/renderer/src/styles/Queuedle.module.css +++ b/renderer/src/styles/Queuedle.module.css @@ -751,7 +751,9 @@ border-radius: var(--r-sm); cursor: pointer; white-space: nowrap; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; } .playAgainBtn:hover { background: var(--bg-2); @@ -895,6 +897,50 @@ margin: 0 0 12px; } +.leaderboardTabs { + display: inline-flex; + gap: 4px; + padding: 3px; + margin: 0 0 16px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); + border-radius: var(--r-sm); +} + +.leaderboardTab { + background: transparent; + border: 0; + color: var(--text-3); + font-family: inherit; + font-size: 12px; + font-weight: 600; + padding: 5px 12px; + border-radius: calc(var(--r-sm) - 2px); + cursor: pointer; + transition: + background 0.12s, + color 0.12s; +} + +.leaderboardTab:hover { + color: var(--text); +} + +.leaderboardTabActive { + background: var(--text); + color: var(--bg); +} + +.leaderboardTabActive:hover { + color: var(--bg); +} + +.rankedState { + padding: 12px 4px; + color: var(--text-3); + font-size: 13px; +} + /* ── Leaderboard rows ── */ .scoreRow { display: grid; @@ -925,6 +971,18 @@ color: var(--text-3); font-variant-numeric: tabular-nums; } +.rankedTier { + display: inline-flex; + align-items: center; + gap: 4px; + vertical-align: middle; +} +.rankedTierIcon { + width: 16px; + height: 16px; + object-fit: contain; + flex-shrink: 0; +} .scoreTotal { font-size: 18px; font-weight: 700; diff --git a/renderer/src/styles/TopNav.module.css b/renderer/src/styles/TopNav.module.css index 0d9b4e2..1614dff 100644 --- a/renderer/src/styles/TopNav.module.css +++ b/renderer/src/styles/TopNav.module.css @@ -13,7 +13,7 @@ .navRoot { position: fixed; top: 12px; - left: 50%; + left: calc((100vw - var(--docked-queue-w, 0px)) / 2); transform: translateX(-50%); z-index: 200; display: flex; diff --git a/renderer/src/styles/global.css b/renderer/src/styles/global.css index 9a8de38..faf5f3c 100644 --- a/renderer/src/styles/global.css +++ b/renderer/src/styles/global.css @@ -26,25 +26,25 @@ user-select: none; } -input[type="text"], +input[type='text'], input:not([type]), textarea { user-select: text; } @font-face { - font-family: "San Francisco"; + font-family: 'San Francisco'; font-weight: 400; - src: url("https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-regular-webfont.woff"); + src: url('https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-regular-webfont.woff'); } body { font-family: - "San Francisco", + 'San Francisco', -apple-system, BlinkMacSystemFont, - "SF Pro Text", - "Segoe UI", + 'SF Pro Text', + 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); @@ -76,16 +76,21 @@ html[data-mini] #root { } ::-webkit-scrollbar { - width: 4px; - height: 4px; + width: 10px; + height: 10px; + background: transparent; } ::-webkit-scrollbar-track { background: transparent; + margin-top: 20px; + margin-bottom: 8px; } ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 99px; } ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); + background-color: rgba(255, 255, 255, 0.22); } diff --git a/src/main.ts b/src/main.ts index 5acf7ea..70c04c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1078,8 +1078,10 @@ function onAuthReady(): void { function createUIWindow(): void { uiWin = new BrowserWindow({ - width: 960, - height: 640, + width: 1280, + height: 720, + minWidth: 864 + 280, // player bar (800) + 32px each side + min queue width + minHeight: 480, title: `True-Tunes v${app.getVersion()}`, backgroundColor: '#1c1c1e', frame: false,