From 158a010583689399a8c5bd3a5c5d9b2883c4cc49 Mon Sep 17 00:00:00 2001 From: Richard Kelsey <65765317+RichKelsey@users.noreply.github.com> Date: Mon, 18 May 2026 21:53:31 +0100 Subject: [PATCH] Rk/queuedle adjust (#87) * Refactor: Queuedle game item overlap logic * Feat: Add 'select album' button to queue sidebar * Feat: Add volume step buttons to PlayerBar * Feat: Queuedle ranking system enhancements * Feat: Queuedle post-game rank change screen * feat(playerbar): Introduce artistItem in useNowPlaying hook * feat(playerbar): Separate track and artist display, make artist clickable * test(playerbar): Update track info test for separate track/artist * refactor(volume): extract shared volume commitment logic * feat(volume): add mute/unmute and hover-based popover * feat: Add Genius API bridge for album year * feat(renderer): Implement useGeniusAlbumYear hook * feat(album-panel): Display album year from Genius --- renderer/src/components/PlayerBar.tsx | 122 ++++++++++++--- .../components/__tests__/PlayerBar.test.tsx | 44 ++++-- renderer/src/components/album/AlbumPanel.tsx | 7 +- .../src/components/queue/QueueSidebar.tsx | 58 +++++-- .../src/components/queuedle/QueuedlePanel.tsx | 72 ++++++++- .../queuedle/QueuedleRankChange.tsx | 111 +++++++++++++ .../queuedle/__tests__/QueuedlePanel.test.tsx | 27 +++- renderer/src/hooks/useDailyGame.ts | 10 ++ renderer/src/hooks/useGeniusAlbumYear.ts | 23 +++ renderer/src/hooks/useNowPlaying.ts | 8 + .../src/lib/__tests__/queueHelpers.test.ts | 51 +++++- renderer/src/lib/queueHelpers.ts | 30 ++++ renderer/src/styles/PlayerBar.module.css | 66 ++++++++ renderer/src/styles/QueueSidebar.module.css | 20 +++ renderer/src/styles/Queuedle.module.css | 146 ++++++++++++++++++ renderer/src/test/setup.ts | 1 + renderer/src/types/globals.d.ts | 1 + server/src/shared/gameGenerator.ts | 7 +- src/main.ts | 38 +++++ src/preload.ts | 3 + 20 files changed, 789 insertions(+), 56 deletions(-) create mode 100644 renderer/src/components/queuedle/QueuedleRankChange.tsx create mode 100644 renderer/src/hooks/useGeniusAlbumYear.ts diff --git a/renderer/src/components/PlayerBar.tsx b/renderer/src/components/PlayerBar.tsx index 3f345df..0d9e193 100644 --- a/renderer/src/components/PlayerBar.tsx +++ b/renderer/src/components/PlayerBar.tsx @@ -21,6 +21,8 @@ import { PictureInPicture2, MicVocal, Heart, + ChevronUp, + ChevronDown, } from "lucide-react"; import type { PlaybackState } from "../hooks/usePlayback"; import styles from "../styles/PlayerBar.module.css"; @@ -84,7 +86,8 @@ function VolumeButton({ volume }: { volume: number }) { const [open, setOpen] = useState(false); const [localVol, setLocalVol] = useState(volume); const debounceRef = useRef | null>(null); - const wrapRef = useRef(null); + const closeRef = useRef | null>(null); + const preMuteRef = useRef(volume > 0 ? volume : 50); // Sync incoming WS volume only when the user isn't actively dragging const dragging = useRef(false); @@ -92,9 +95,7 @@ function VolumeButton({ volume }: { volume: number }) { if (!dragging.current) setLocalVol(volume); }, [volume]); - const handleChange = (e: React.ChangeEvent) => { - const val = Number(e.target.value); - setLocalVol(val); + const commitVolume = (val: number) => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { getActiveProvider().setVolume(val); @@ -102,22 +103,75 @@ function VolumeButton({ volume }: { volume: number }) { }, 150); }; + const handleChange = (e: React.ChangeEvent) => { + const val = Number(e.target.value); + setLocalVol(val); + if (val > 0) preMuteRef.current = val; + commitVolume(val); + }; + + const step = (delta: number) => { + setLocalVol((prev) => { + const next = Math.max(0, Math.min(100, prev + delta)); + if (next === prev) return prev; + if (next > 0) preMuteRef.current = next; + commitVolume(next); + return next; + }); + }; + + const toggleMute = () => { + if (localVol > 0) { + preMuteRef.current = localVol; + setLocalVol(0); + commitVolume(0); + } else { + const restored = preMuteRef.current > 0 ? preMuteRef.current : 50; + setLocalVol(restored); + commitVolume(restored); + } + }; + + const handleEnter = () => { + if (closeRef.current) { + clearTimeout(closeRef.current); + closeRef.current = null; + } + setOpen(true); + }; + + const handleLeave = () => { + if (closeRef.current) clearTimeout(closeRef.current); + closeRef.current = setTimeout(() => setOpen(false), 150); + }; + useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { - setOpen(false); - } + return () => { + if (closeRef.current) clearTimeout(closeRef.current); + if (debounceRef.current) clearTimeout(debounceRef.current); }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [open]); + }, []); + + const muted = localVol === 0; return ( -
+
{open && (
{localVol} + +
)} @@ -151,7 +215,7 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) displayTrack, displayArtist, cachedArt, dominantColor, elapsedLabel, durationLabel, progressPct, durationMs, isPlaying, isVisible, shuffle, repeat, volume, isExplicit, - albumItem, prefetchAlbum, + albumItem, artistItem, prefetchAlbum, currentObjectId, currentServiceId, currentAccountId, artUrlRaw, } = useNowPlaying(playback); @@ -218,17 +282,27 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) )}
-
+
+ {displayArtist && ( + <> + {' – '} + {artistItem ? ( + + ) : ( + {displayArtist} + )} + + )} {isExplicit && }
diff --git a/renderer/src/components/__tests__/PlayerBar.test.tsx b/renderer/src/components/__tests__/PlayerBar.test.tsx index e8ff8a2..0614eab 100644 --- a/renderer/src/components/__tests__/PlayerBar.test.tsx +++ b/renderer/src/components/__tests__/PlayerBar.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, act } from '@testing-library/react'; +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PlayerBar } from '../PlayerBar'; @@ -144,7 +144,8 @@ describe('PlayerBar — visibility', () => { describe('PlayerBar — track info', () => { it('displays track and artist name', () => { setup(); - expect(screen.getByText('Test Track - Test Artist')).toBeInTheDocument(); + expect(screen.getByText('Test Track')).toBeInTheDocument(); + expect(screen.getByText('Test Artist')).toBeInTheDocument(); }); it('shows elapsed and duration labels', () => { @@ -307,25 +308,42 @@ describe('PlayerBar — seek bar', () => { // ─── volume ─────────────────────────────────────────────────────────────────── describe('PlayerBar — volume button', () => { - it('clicking Volume button shows the popover', async () => { - const { user } = setup(); - await user.click(screen.getByTitle('Volume')); + it('hovering the Volume button shows the popover', () => { + const { container } = setup(); + const wrap = container.querySelector('[class*="volWrap"]') as HTMLElement; + fireEvent.mouseEnter(wrap); expect(screen.getByRole('slider')).toBeInTheDocument(); }); - it('clicking outside volume popover closes it', async () => { - const { user } = setup(); - await user.click(screen.getByTitle('Volume')); + it('moving the mouse away closes the popover after a delay', async () => { + const { container } = setup(); + const wrap = container.querySelector('[class*="volWrap"]') as HTMLElement; + fireEvent.mouseEnter(wrap); expect(screen.getByRole('slider')).toBeInTheDocument(); - await user.click(document.body); - expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + fireEvent.mouseLeave(wrap); + await waitFor(() => expect(screen.queryByRole('slider')).not.toBeInTheDocument()); }); - it('shows current volume percentage in popover', async () => { - const { user } = setup({ volume: 72 }, { volume: 72 }); - await user.click(screen.getByTitle('Volume')); + it('shows current volume percentage in popover', () => { + const { container } = setup({ volume: 72 }, { volume: 72 }); + const wrap = container.querySelector('[class*="volWrap"]') as HTMLElement; + fireEvent.mouseEnter(wrap); expect(screen.getByText('72')).toBeInTheDocument(); }); + + it('clicking the icon mutes when volume is non-zero', async () => { + setup({ volume: 60 }, { volume: 60 }); + fireEvent.click(screen.getByTitle('Mute')); + await waitFor(() => expect(window.sonos.setGroupVolume).toHaveBeenCalledWith(0)); + }); + + it('clicking the icon while muted restores the previous volume', async () => { + setup({ volume: 60 }, { volume: 60 }); + fireEvent.click(screen.getByTitle('Mute')); + await waitFor(() => expect(window.sonos.setGroupVolume).toHaveBeenLastCalledWith(0)); + fireEvent.click(screen.getByTitle('Unmute')); + await waitFor(() => expect(window.sonos.setGroupVolume).toHaveBeenLastCalledWith(60)); + }); }); // ─── right-side controls ───────────────────────────────────────────────────── diff --git a/renderer/src/components/album/AlbumPanel.tsx b/renderer/src/components/album/AlbumPanel.tsx index f7725cc..692236f 100644 --- a/renderer/src/components/album/AlbumPanel.tsx +++ b/renderer/src/components/album/AlbumPanel.tsx @@ -5,6 +5,7 @@ import { useImage } from '../../hooks/useImage'; import { useAlbumBrowse } from '../../hooks/useAlbumBrowse'; import { usePlaylistBrowse } from '../../hooks/usePlaylistBrowse'; import { useDominantColor } from '../../hooks/useDominantColor'; +import { useGeniusAlbumYear } from '../../hooks/useGeniusAlbumYear'; import { artistQueryOptions } from '../../hooks/useArtistBrowse'; import { resolveAlbumParams, isPlaylist, isProgram, getItemArt } from '../../lib/itemHelpers'; import { createDragGhost } from '../../lib/dragHelpers'; @@ -53,6 +54,10 @@ export function AlbumPanel({ onAddToQueue }: Props) { const artUrl = data?.artUrl ?? (item ? getItemArt(item) : null); const cachedArt = useImage(artUrl); const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); + const year = useGeniusAlbumYear( + isPlaylistOrProgram ? null : title, + isPlaylistOrProgram ? null : artist, + ); useEffect(() => { setSelected(new Set()); @@ -120,7 +125,7 @@ export function AlbumPanel({ onAddToQueue }: Props) { )} {data && (
- {[data.totalTracks + ' songs', totalMins > 0 ? totalMins + ' min' : null].filter(Boolean).join(' \u2022 ')} + {[data.totalTracks + ' songs', totalMins > 0 ? totalMins + ' min' : null, year].filter(Boolean).join(' \u2022 ')}
)}
diff --git a/renderer/src/components/queue/QueueSidebar.tsx b/renderer/src/components/queue/QueueSidebar.tsx index fa481a2..e17d302 100644 --- a/renderer/src/components/queue/QueueSidebar.tsx +++ b/renderer/src/components/queue/QueueSidebar.tsx @@ -1,7 +1,7 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState, Fragment, forwardRef } from 'react'; import { useQueries } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; -import { applyReorderLocally } from '../../lib/queueHelpers'; +import { applyReorderLocally, expandToAlbumBlock } from '../../lib/queueHelpers'; import { createDragGhost } from '../../lib/dragHelpers'; import { getActiveProvider } from '../../providers'; import { useAttribution } from '../../hooks/useAttribution'; @@ -79,6 +79,19 @@ export const QueueSidebar = forwardRef(function Queue })), }); + // Album ids per item, resolved from useTrackDetails (the same data the queue row + // uses to render the album link) with the raw NormalizedTrack value as fallback. + // Sonos rarely embeds album info on raw queue rows so the resolved value is what + // the "Select album" button must match against. + const resolvedAlbumIds = useMemo<(string | null)[]>( + () => + items.map((item, i) => { + const fromDetails = trackDetailsResults[i]?.data?.albumId; + return (fromDetails ?? item.track.albumId) || null; + }), + [items, trackDetailsResults], + ); + const [nowMs, setNowMs] = useState(0); useEffect(() => { setNowMs(Date.now()); }, [positionMs]); @@ -346,14 +359,41 @@ onClick={handleContentClick} {selCount > 0 && (
{selCount} track{selCount !== 1 ? 's' : ''} selected - +
+ {selCount === 1 && (() => { + const anchor = [...selected][0]; + const anchorTrack = items[anchor]?.track; + if (!anchorTrack) return null; + const block = expandToAlbumBlock(items.length, anchor, resolvedAlbumIds); + // Hide the button when there's nothing to expand to — keeps the bar + // from offering an action that would be a no-op (or that grabbed the + // whole queue back when this had an artist fallback). + if (block.size <= 1) return null; + const resolvedAlbumName = + trackDetailsResults[anchor]?.data?.albumName ?? anchorTrack.albumName; + const tooltip = `Expand to all ${block.size} tracks from ${resolvedAlbumName ?? 'this album'} in sequence`; + return ( + + ); + })()} + +
)}
diff --git a/renderer/src/components/queuedle/QueuedlePanel.tsx b/renderer/src/components/queuedle/QueuedlePanel.tsx index 1c730b1..987d4de 100644 --- a/renderer/src/components/queuedle/QueuedlePanel.tsx +++ b/renderer/src/components/queuedle/QueuedlePanel.tsx @@ -8,11 +8,16 @@ import { useGameRankings, useMyScore, useGameStats, + computeQueuedleRating, + getGameRankTier, } from '../../hooks/useDailyGame'; +import type { GameRanking } from '../../hooks/useDailyGame'; import { QueuedleIntro } from './QueuedleIntro'; import { QueuedleQuestionCard } from './QueuedleQuestionCard'; import { QueuedleBonusScreen } from './QueuedleBonusScreen'; import { QueuedleBonusResults } from './QueuedleBonusResults'; +import { QueuedleRankChange } from './QueuedleRankChange'; +import type { RankSnapshot } from './QueuedleRankChange'; import { QueuedleSummary } from './QueuedleSummary'; import { QueuedleCalendar } from './QueuedleCalendar'; import { ScoreDistribution } from './ScoreDistribution'; @@ -20,7 +25,7 @@ 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 Phase = 'intro' | 'main' | 'bonus' | 'bonus-results' | 'rank-change' | 'summary'; type LeaderboardTab = 'today' | 'ranked'; function londonDateToday(): string { @@ -55,7 +60,9 @@ export function QueuedlePanel() { const leaderboard = useGameLeaderboard(gameId ?? undefined); const gameDates = useGameDates(displayName ?? undefined); - const rankings = useGameRankings(displayName ?? undefined, leaderboardTab === 'ranked'); + // Always fetch rankings (not just when "Ranked" tab is open) so the post-game + // rank-change screen has the user's prior totals available. + const rankings = useGameRankings(displayName ?? undefined, true); const myScore = useMyScore(gameId, displayName); const gameStats = useGameStats(gameId); const alreadyPlayed = useMemo(() => { @@ -96,6 +103,9 @@ export function QueuedlePanel() { const [localScore, setLocalScore] = useState<{ main: number; bonus: number } | null>(null); const [calendarOpen, setCalendarOpen] = useState(false); const [devReplay, setDevReplay] = useState(false); + // Snapshot of the user's prior ranking row, captured once before today's submission. + // undefined = not captured yet; null = captured, user has no prior games. + const [beforeRanking, setBeforeRanking] = useState(undefined); // Backfill localStorage from the cloud when the leaderboard shows the user has // played but the local key is missing (fresh install, cleared cache, new machine). @@ -129,6 +139,48 @@ export function QueuedlePanel() { } }, [game, bonusSelections.length]); + // Capture the user's prior rankings row exactly once, before today's submission + // refetches the rankings query (which would include today's score and ruin the delta). + useEffect(() => { + if (!displayName) return; + if (beforeRanking !== undefined) return; + if (rankings.isLoading) return; + if (localScore !== null) return; // user already submitted; don't capture after the fact + const row = rankings.data?.find((r) => r.userName === displayName) ?? null; + setBeforeRanking(row); + }, [displayName, rankings.data, rankings.isLoading, beforeRanking, localScore]); + + const afterRanking = useMemo(() => { + if (!localScore || !game || beforeRanking === undefined) return null; + const todayScore = localScore.main + localScore.bonus; + const todayMax = game.questions.length * 2; + const totalScore = (beforeRanking?.totalScore ?? 0) + todayScore; + const possibleScore = (beforeRanking?.possibleScore ?? 0) + todayMax; + const gamesPlayed = (beforeRanking?.gamesPlayed ?? 0) + 1; + const averagePercent = possibleScore > 0 ? (totalScore / possibleScore) * 100 : 0; + const tier = getGameRankTier(averagePercent, gamesPlayed); + return { + rating: computeQueuedleRating(averagePercent), + averagePercent, + gamesPlayed, + tierKey: tier.key, + tierName: tier.name, + isProvisional: tier.isProvisional, + }; + }, [localScore, game, beforeRanking]); + + const beforeRankSnapshot = useMemo(() => { + if (!beforeRanking) return null; + return { + rating: computeQueuedleRating(beforeRanking.averagePercent), + averagePercent: beforeRanking.averagePercent, + gamesPlayed: beforeRanking.gamesPlayed, + tierKey: beforeRanking.tierKey, + tierName: beforeRanking.tierName, + isProvisional: beforeRanking.isProvisional, + }; + }, [beforeRanking]); + function resetGameState() { setPhase('intro'); setCurrentIdx(0); @@ -138,6 +190,7 @@ export function QueuedlePanel() { setBonusSelections([]); setLocalScore(null); setDevReplay(false); + setBeforeRanking(undefined); } function handleDevPlayAgain() { @@ -524,7 +577,7 @@ export function QueuedlePanel() { {headerActions}
- {phase !== 'intro' && phase !== 'summary' && phase !== 'bonus-results' && ( + {phase !== 'intro' && phase !== 'summary' && phase !== 'bonus-results' && phase !== 'rank-change' && (
{pips.map((cls, i) => (
@@ -559,6 +612,19 @@ export function QueuedlePanel() { { + // Skip the rank-change beat for dev replays (no real submission) and + // when we couldn't compute an "after" snapshot (e.g. no displayName). + if (devReplay || !afterRanking) setPhase('summary'); + else setPhase('rank-change'); + }} + /> + )} + + {phase === 'rank-change' && afterRanking && ( + setPhase('summary')} /> )} diff --git a/renderer/src/components/queuedle/QueuedleRankChange.tsx b/renderer/src/components/queuedle/QueuedleRankChange.tsx new file mode 100644 index 0000000..0659b15 --- /dev/null +++ b/renderer/src/components/queuedle/QueuedleRankChange.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { getGameRankIcon } from '../../lib/gameRankAssets'; +import type { GameRankTierKey } from '../../hooks/useDailyGame'; +import styles from '../../styles/Queuedle.module.css'; + +export interface RankSnapshot { + rating: number; + averagePercent: number; + gamesPlayed: number; + tierKey: GameRankTierKey; + tierName: string; + isProvisional: boolean; +} + +interface Props { + before: RankSnapshot | null; + after: RankSnapshot; + onContinue: () => void; +} + +const ANIM_MS = 1200; + +function useTween(from: number, to: number, durationMs: number) { + const [value, setValue] = useState(from); + const startRef = useRef(null); + const rafRef = useRef(null); + + useEffect(() => { + startRef.current = null; + const tick = (now: number) => { + if (startRef.current === null) startRef.current = now; + const t = Math.min(1, (now - startRef.current) / durationMs); + const eased = 1 - Math.pow(1 - t, 3); + setValue(Math.round(from + (to - from) * eased)); + if (t < 1) rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, [from, to, durationMs]); + + return value; +} + +export function QueuedleRankChange({ before, after, onContinue }: Props) { + const fromRating = before?.rating ?? after.rating; + const display = useTween(fromRating, after.rating, ANIM_MS); + const delta = before ? after.rating - before.rating : 0; + const tierChanged = !!before && before.tierKey !== after.tierKey; + const tierIcon = getGameRankIcon(after.tierKey); + const prevTierIcon = before ? getGameRankIcon(before.tierKey) : null; + + const deltaLabel = delta > 0 ? `+${delta}` : `${delta}`; + const deltaClass = + delta > 0 + ? styles.rankDeltaUp + : delta < 0 + ? styles.rankDeltaDown + : styles.rankDeltaFlat; + + return ( +
+

+ {before ? 'Rank update' : 'Welcome to the ranks'} +

+ +
+ {tierChanged && prevTierIcon && ( +
+ +
+ )} + {tierChanged && prevTierIcon && ( +
+ )} +
+ {tierIcon ? ( + + ) : ( +
+ )} +
+
+ +
{after.tierName}
+ +
+ {display} + {before && !after.isProvisional && !before.isProvisional && delta !== 0 && ( + {deltaLabel} + )} +
+ + {after.isProvisional && ( +
+ Provisional · play {Math.max(0, 3 - after.gamesPlayed)} more + {Math.max(0, 3 - after.gamesPlayed) === 1 ? ' game' : ' games'} to lock in a tier +
+ )} + +
+ +
+
+ ); +} diff --git a/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx b/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx index 5262ab7..43ea589 100644 --- a/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx +++ b/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx @@ -21,6 +21,16 @@ vi.mock('@/hooks/useDailyGame', () => ({ 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), + computeQueuedleRating: (averagePercent: number) => + Math.round(Math.max(0, Math.min(100, averagePercent)) * 30), + getGameRankTier: (averagePercent: number, gamesPlayed: number) => { + if (gamesPlayed < 3) return { key: 'provisional', name: 'Provisional', 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 }; + }, })); vi.mock('../QueuedleIntro', () => ({ @@ -71,6 +81,14 @@ vi.mock('../QueuedleBonusResults', () => ({
), })); +vi.mock('../QueuedleRankChange', () => ({ + QueuedleRankChange: ({ onContinue }: { onContinue: () => void }) => ( +
+ Rank change + +
+ ), +})); vi.mock('../QueuedleSummary', () => ({ QueuedleSummary: ({ mainScore, bonusScore }: { mainScore: number; bonusScore: number }) => (
Score: {mainScore + bonusScore}
@@ -280,6 +298,7 @@ describe('QueuedlePanel', () => { await user.click(screen.getByText('Submit Bonus')); await waitFor(() => screen.getByText('Bonus results')); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); expect(screen.getByText('Score: 2')).toBeInTheDocument(); }); @@ -294,6 +313,7 @@ describe('QueuedlePanel', () => { await user.click(screen.getByText('Submit Bonus')); await waitFor(() => screen.getByText('Bonus results')); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); expect(screen.getByText('Score: 2')).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Ranked' })).toBeInTheDocument(); }); @@ -309,6 +329,7 @@ describe('QueuedlePanel', () => { await user.click(screen.getByText('Submit Bonus')); await waitFor(() => screen.getByText('Bonus results')); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); expect(screen.getByText('Score: 1')).toBeInTheDocument(); }); @@ -324,6 +345,7 @@ describe('QueuedlePanel', () => { await user.click(screen.getByText('Submit Bonus')); await waitFor(() => screen.getByText('Bonus results')); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); // main=1 (picked left, winner is left), bonus=0 (bonusSelections all null, topQueuer is 'alice') expect(screen.getByText('Score: 1')).toBeInTheDocument(); }); @@ -349,6 +371,7 @@ describe('QueuedlePanel', () => { await flushPromises(); expect(screen.getByText('Bonus results')).toBeInTheDocument(); await user.click(screen.getByText('See Score')); + await user.click(screen.getByText('Continue Rank')); expect(screen.getByText('Score: 1')).toBeInTheDocument(); }); @@ -406,7 +429,9 @@ describe('QueuedlePanel', () => { localStorage.setItem('queuedle-played:2024-01-01', JSON.stringify({ mainScore: 2, bonusScore: 1 })); render(); await waitFor(() => expect(screen.getByRole('tab', { name: 'Ranked' })).toBeInTheDocument()); - await waitFor(() => expect(mockUseGameRankings).toHaveBeenLastCalledWith('TestUser', false)); + // Rankings are now always enabled so the post-game rank-change screen has the + // user's prior totals available without a tab switch. + await waitFor(() => expect(mockUseGameRankings).toHaveBeenLastCalledWith('TestUser', true)); }); it('clicking Ranked shows average total rankings', async () => { diff --git a/renderer/src/hooks/useDailyGame.ts b/renderer/src/hooks/useDailyGame.ts index fc856a4..7ab4c52 100644 --- a/renderer/src/hooks/useDailyGame.ts +++ b/renderer/src/hooks/useDailyGame.ts @@ -7,12 +7,20 @@ export interface GameRanking { averageMain: number; averageBonus: number; averagePercent: number; + totalScore: number; + possibleScore: number; bestTotal: number; tierKey: GameRankTierKey; tierName: string; isProvisional: boolean; } +// 0-100% mapped to a 0-3000 SR-like rating, used by the post-game rank-change screen. +export function computeQueuedleRating(averagePercent: number): number { + if (!Number.isFinite(averagePercent)) return 0; + return Math.round(Math.max(0, Math.min(100, averagePercent)) * 30); +} + interface RankingAccumulator { userName: string; gamesPlayed: number; @@ -124,6 +132,8 @@ export function calculateGameRankings(sources: GameRankingSource[]): GameRanking averageMain: entry.mainScore / entry.gamesPlayed, averageBonus: entry.bonusScore / entry.gamesPlayed, averagePercent, + totalScore: entry.totalScore, + possibleScore: entry.possibleScore, bestTotal: entry.bestTotal, tierKey: tier.key, tierName: tier.name, diff --git a/renderer/src/hooks/useGeniusAlbumYear.ts b/renderer/src/hooks/useGeniusAlbumYear.ts new file mode 100644 index 0000000..e0d7cbd --- /dev/null +++ b/renderer/src/hooks/useGeniusAlbumYear.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; + +export function geniusAlbumYearQueryOptions( + albumName: string | null | undefined, + artistName: string | null | undefined, +) { + return { + queryKey: ['genius-album-year', albumName, artistName] as const, + queryFn: (): Promise => + window.sonos.geniusAlbumYear(albumName!, artistName!), + staleTime: Infinity, + retry: false, + enabled: !!albumName && !!artistName, + }; +} + +export function useGeniusAlbumYear( + albumName: string | null | undefined, + artistName: string | null | undefined, +) { + const { data } = useQuery(geniusAlbumYearQueryOptions(albumName, artistName)); + return data ?? null; +} diff --git a/renderer/src/hooks/useNowPlaying.ts b/renderer/src/hooks/useNowPlaying.ts index af21ad2..a3aa05e 100644 --- a/renderer/src/hooks/useNowPlaying.ts +++ b/renderer/src/hooks/useNowPlaying.ts @@ -46,6 +46,13 @@ export function useNowPlaying(playback: PlaybackState) { resource: { type: 'ALBUM', id: { objectId: albumId, serviceId: svcId, accountId: accId } }, } as SonosItem : null; + const artistId = td?.artistId; + const artistItem: SonosItem | null = artistId ? { + title: displayArtist ?? '', + type: 'ARTIST', + resource: { type: 'ARTIST', id: { objectId: artistId, serviceId: svcId, accountId: accId } }, + } as SonosItem : null; + return { // Display values displayTrack, displayArtist, albumName, albumId, @@ -61,6 +68,7 @@ export function useNowPlaying(playback: PlaybackState) { artUrlRaw: td?.artUrl ?? artUrl ?? null, // Derived albumItem, + artistItem, prefetchAlbum, }; } diff --git a/renderer/src/lib/__tests__/queueHelpers.test.ts b/renderer/src/lib/__tests__/queueHelpers.test.ts index ae94b13..baa6f18 100644 --- a/renderer/src/lib/__tests__/queueHelpers.test.ts +++ b/renderer/src/lib/__tests__/queueHelpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { applyReorderLocally } from '../queueHelpers'; +import { applyReorderLocally, expandToAlbumBlock } from '../queueHelpers'; // Using plain strings — applyReorderLocally is generic, no QueueItem needed here. const items = ['A', 'B', 'C', 'D', 'E']; @@ -75,3 +75,52 @@ describe('applyReorderLocally', () => { expect(applyReorderLocally(['A', 'B', 'C'], [0], 3)).toEqual(['B', 'C', 'A']); }); }); + +describe('expandToAlbumBlock', () => { + it('selects a contiguous run of tracks sharing an albumId', () => { + const albumIds = ['A', 'A', 'A', 'B']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)].sort((a, b) => a - b)).toEqual([0, 1, 2]); + }); + + it('stops at the boundary of a different album', () => { + const albumIds = ['A', 'B', 'B', 'A']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)].sort((a, b) => a - b)).toEqual([1, 2]); + }); + + it('returns just the anchor when anchor has no albumId (no artist fallback)', () => { + const albumIds = [null, null, null]; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); + + it('returns just the anchor when an empty-string albumId is passed', () => { + const albumIds = ['', '', '']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); + + it('returns just the anchor when neighbours have different albums', () => { + const albumIds = ['A', 'B', 'C']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)]).toEqual([1]); + }); + + it('handles anchor at the start of the list', () => { + const albumIds = ['A', 'A', 'B']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)].sort((a, b) => a - b)).toEqual([0, 1]); + }); + + it('handles anchor at the end of the list', () => { + const albumIds = ['B', 'A', 'A']; + expect([...expandToAlbumBlock(albumIds.length, 2, albumIds)].sort((a, b) => a - b)).toEqual([1, 2]); + }); + + it('returns just the anchor when anchor is out of bounds', () => { + const albumIds = ['A']; + expect([...expandToAlbumBlock(albumIds.length, 5, albumIds)]).toEqual([5]); + }); + + it('does not run away when missing-albumId neighbours sit between two same-album items', () => { + // Regression: previous artist-fallback code could grab unmatched neighbours. + // With a strict same-album-id match, a null between two A's stops expansion. + const albumIds = ['A', null, 'A']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); +}); diff --git a/renderer/src/lib/queueHelpers.ts b/renderer/src/lib/queueHelpers.ts index c102bcb..880f775 100644 --- a/renderer/src/lib/queueHelpers.ts +++ b/renderer/src/lib/queueHelpers.ts @@ -6,3 +6,33 @@ export function applyReorderLocally(items: T[], fromIndices: number[], toInde const movers = [...fromIndices].sort((a, b) => a - b).map(i => items[i]); return [...remaining.slice(0, insertAt), ...movers, ...remaining.slice(insertAt)]; } + +// Returns the indices of the contiguous run of queue items that share the anchor's +// album id. Always includes the anchor. If the anchor has no resolved album id, or +// no adjacent neighbour matches, returns just the anchor (no artist fallback — +// matching by artist alone tends to grab the entire queue when every track has the +// same artist or empty/unresolved artist). +// +// `albumIds` is aligned to the queue items. Callers must supply the merged album id +// (e.g. NormalizedTrack values overlaid with whatever `useTrackDetails` resolved) +// because Sonos doesn't reliably embed album info on raw queue rows. +export function expandToAlbumBlock( + itemCount: number, + anchor: number, + albumIds: (string | null)[], +): Set { + if (anchor < 0 || anchor >= itemCount) return new Set([anchor]); + const anchorAlbum = albumIds[anchor]; + if (!anchorAlbum) return new Set([anchor]); + + const result = new Set([anchor]); + for (let i = anchor - 1; i >= 0; i--) { + if (albumIds[i] !== anchorAlbum) break; + result.add(i); + } + for (let i = anchor + 1; i < itemCount; i++) { + if (albumIds[i] !== anchorAlbum) break; + result.add(i); + } + return result; +} diff --git a/renderer/src/styles/PlayerBar.module.css b/renderer/src/styles/PlayerBar.module.css index 47f105c..b455640 100644 --- a/renderer/src/styles/PlayerBar.module.css +++ b/renderer/src/styles/PlayerBar.module.css @@ -72,12 +72,47 @@ flex-shrink: 0; overflow: hidden; } +.trackLine { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; +} .trackName { font-size: 13px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; + min-width: 0; +} +.trackSep { + font-size: 13px; + font-weight: 600; + color: #fff; + flex-shrink: 0; + white-space: pre; +} +.trackArtist { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 0 1 auto; +} +button.trackArtist { + background: none; + border: none; + padding: 0; + font-family: inherit; + cursor: pointer; + transition: color 0.12s; +} +button.trackArtist:hover { + text-decoration: underline; } @keyframes tt-scroll { @@ -254,6 +289,16 @@ animation: volFadeIn 0.12s ease; } +/* Invisible bridge so hover stays continuous between icon and popover */ +.volPopover::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 100%; + height: 8px; +} + @keyframes volFadeIn { from { opacity: 0; @@ -300,3 +345,24 @@ .volSliderV:hover { background: rgba(255, 255, 255, 0.3); } + +.volStep { + background: none; + border: none; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + padding: 2px 6px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.12s, background 0.12s; +} +.volStep:hover:not(:disabled) { + color: #fff; + background: rgba(255, 255, 255, 0.08); +} +.volStep:disabled { + opacity: 0.3; + cursor: default; +} diff --git a/renderer/src/styles/QueueSidebar.module.css b/renderer/src/styles/QueueSidebar.module.css index 3fc0bfa..3b8a475 100644 --- a/renderer/src/styles/QueueSidebar.module.css +++ b/renderer/src/styles/QueueSidebar.module.css @@ -337,6 +337,12 @@ .selBar span { font-size: 12px; color: var(--text-2); } +.selActions { + display: flex; + align-items: center; + gap: 8px; +} + .selDelBtn { background: none; border: 1px solid rgba(255,80,80,0.4); @@ -351,6 +357,20 @@ color: #ff6464; } +.selExpandBtn { + background: none; + border: 1px solid rgba(255,255,255,0.2); + color: var(--text-2); + font-size: 11px; font-weight: 600; font-family: inherit; + padding: 4px 12px; border-radius: 12px; + cursor: pointer; transition: all 0.15s; +} +.selExpandBtn:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.35); + color: var(--text); +} + /* ── Misc ── */ .msg { padding: 28px 16px; diff --git a/renderer/src/styles/Queuedle.module.css b/renderer/src/styles/Queuedle.module.css index eb74128..ec18309 100644 --- a/renderer/src/styles/Queuedle.module.css +++ b/renderer/src/styles/Queuedle.module.css @@ -1247,3 +1247,149 @@ background: var(--bg-2); color: var(--text); } + +/* ── Rank change screen ── */ +.rankChangeWrap { + max-width: 480px; + margin: 32px auto 0; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.rankChangeTitle { + font-size: 20px; + font-weight: 700; + color: var(--text); + margin: 0 0 24px; + letter-spacing: -0.01em; +} + +.rankChangeBadgeRow { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; +} + +.rankBadgePrev, +.rankBadgeCurrent { + width: 96px; + height: 96px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.rankBadgePrev { + opacity: 0.45; + transform: scale(0.85); +} + +.rankBadgeImg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.rankBadgePlaceholder { + width: 60%; + height: 60%; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); +} + +.rankBadgeArrow { + font-size: 20px; + color: var(--text-3); +} + +@keyframes rankBadgePulse { + 0% { + transform: scale(0.85); + box-shadow: 0 0 0 0 rgba(29, 185, 84, 0.5); + } + 50% { + transform: scale(1.06); + box-shadow: 0 0 0 16px rgba(29, 185, 84, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(29, 185, 84, 0); + } +} + +.rankBadgePulse { + animation: rankBadgePulse 1.2s ease-out; +} + +.rankTierName { + font-size: 16px; + font-weight: 600; + color: var(--text); + margin-bottom: 18px; + letter-spacing: 0.02em; +} + +.rankRatingRow { + display: flex; + align-items: baseline; + justify-content: center; + gap: 14px; + margin-bottom: 12px; +} + +.rankRatingNumber { + font-size: 56px; + font-weight: 800; + color: var(--text); + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + line-height: 1; +} + +.rankDeltaChip { + font-size: 14px; + font-weight: 700; + font-variant-numeric: tabular-nums; + padding: 4px 10px; + border-radius: 999px; + animation: rankDeltaIn 0.4s ease-out both; +} + +@keyframes rankDeltaIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.rankDeltaUp { + color: #1db954; + background: rgba(29, 185, 84, 0.15); +} + +.rankDeltaDown { + color: #ff6464; + background: rgba(255, 100, 100, 0.15); +} + +.rankDeltaFlat { + color: var(--text-3); + background: rgba(255, 255, 255, 0.08); +} + +.rankProvisional { + font-size: 12px; + color: var(--text-3); + margin-top: 4px; + margin-bottom: 8px; +} diff --git a/renderer/src/test/setup.ts b/renderer/src/test/setup.ts index 09eaf46..3dd7620 100644 --- a/renderer/src/test/setup.ts +++ b/renderer/src/test/setup.ts @@ -64,6 +64,7 @@ Object.defineProperty(window, 'sonos', { fetchRecentlyPlayed: vi.fn(() => Promise.resolve(null)), geniusDescription: vi.fn(() => Promise.resolve(null)), geniusArtist: vi.fn(() => Promise.resolve(null)), + geniusAlbumYear: vi.fn(() => Promise.resolve(null)), trackEvent: vi.fn(() => Promise.resolve()), minimizeWindow: vi.fn(() => Promise.resolve()), maximizeWindow: vi.fn(() => Promise.resolve()), diff --git a/renderer/src/types/globals.d.ts b/renderer/src/types/globals.d.ts index c3106f5..70ac4d5 100644 --- a/renderer/src/types/globals.d.ts +++ b/renderer/src/types/globals.d.ts @@ -339,6 +339,7 @@ interface SonosPreload { onAttributionEvent: (cb: (event: AttributionEvent) => void) => Unsubscribe; geniusDescription: (trackName: string, artistName: string) => Promise; geniusArtist: (artistName: string, trackHint?: string) => Promise; + geniusAlbumYear: (albumName: string, artistName: string) => Promise; /** Fire-and-forget telemetry event routed through the main process. No-op when App Insights is not configured. */ trackEvent: (name: string, properties?: Record) => Promise; minimizeWindow: () => Promise; diff --git a/server/src/shared/gameGenerator.ts b/server/src/shared/gameGenerator.ts index 57a76f2..96a790f 100644 --- a/server/src/shared/gameGenerator.ts +++ b/server/src/shared/gameGenerator.ts @@ -183,11 +183,10 @@ function buildBonus( } function itemsOverlap(a: GameItem, b: GameItem): boolean { - if (a.category === b.category && a.id === b.id) return true; + if (a.category !== b.category) return true; + if (a.id === b.id) return true; if (a.artistKey && b.artistKey && a.artistKey === b.artistKey) { - if (a.category === 'artist' || b.category === 'artist') return true; - if (a.category === 'album' && b.category === 'track') return true; - if (a.category === 'track' && b.category === 'album') return true; + if (a.category === 'artist') return true; } if (a.albumKey && b.albumKey && a.albumKey === b.albumKey) { return true; diff --git a/src/main.ts b/src/main.ts index a3079e9..624ed05 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1914,6 +1914,44 @@ ipcMain.handle('genius:artist', async (_: IpcMainInvokeEvent, artistName: string } }); +ipcMain.handle('genius:albumYear', async (_: IpcMainInvokeEvent, albumName: string, artistName: string) => { + const key = process.env.GENIUS_ACCESS_TOKEN; + if (!key) { + log('[genius] GENIUS_ACCESS_TOKEN not set — skipping albumYear'); + return null; + } + + const debugReq = (id: string, url: string, ts: number) => + httpDebugWin?.webContents.send('http:req', { id, ts, operationId: 'genius:albumYear', method: 'GET', url, headers: { Authorization: 'Bearer ***' } }); + const debugRes = (id: string, status: number, body: string, ts: number) => + httpDebugWin?.webContents.send('http:res', { id, status, statusText: String(status), headers: {}, body, durationMs: Date.now() - ts }); + + try { + const searchUrl = `https://api.genius.com/search?q=${encodeURIComponent(`${albumName} ${artistName}`)}`; + const searchId = randomUUID(); const searchTs = Date.now(); + debugReq(searchId, searchUrl, searchTs); + const searchRes = await fetch(searchUrl, { headers: { Authorization: `Bearer ${key}` } }); + const searchBody = await searchRes.text(); + debugRes(searchId, searchRes.status, searchBody, searchTs); + if (!searchRes.ok) return null; + + type Hit = { + result: { + primary_artist: { name: string } | null; + release_date_components: { year: number | null } | null; + }; + }; + const searchData = JSON.parse(searchBody) as { response: { hits: Hit[] } }; + const hits = searchData.response?.hits ?? []; + const lower = artistName.toLowerCase(); + const hit = hits.find((h) => h.result.primary_artist?.name?.toLowerCase().includes(lower)) ?? hits[0]; + return hit?.result?.release_date_components?.year ?? null; + } catch (err) { + log(`[genius] albumYear error: ${err}`); + return null; + } +}); + // ─── Application menu ──────────────────────────────────────────────────────── function buildMenu(): void { diff --git a/src/preload.ts b/src/preload.ts index 007ea24..f4dd24e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -60,6 +60,7 @@ export interface SonosAPI { fetchRecentlyPlayed: (userId: string) => Promise; geniusDescription: (trackName: string, artistName: string) => Promise; geniusArtist: (artistName: string, trackHint?: string) => Promise; + geniusAlbumYear: (albumName: string, artistName: string) => Promise; trackEvent: (name: string, properties?: Record) => Promise; fetchPlaylists: (filter: { owner?: string; member?: string }) => Promise; fetchPlaylist: (id: string) => Promise; @@ -194,6 +195,8 @@ contextBridge.exposeInMainWorld('sonos', { ipcRenderer.invoke('genius:description', trackName, artistName), geniusArtist: (artistName: string, trackHint?: string) => ipcRenderer.invoke('genius:artist', artistName, trackHint), + geniusAlbumYear: (albumName: string, artistName: string) => + ipcRenderer.invoke('genius:albumYear', albumName, artistName), trackEvent: (name: string, properties?: Record) => ipcRenderer.invoke('telemetry:event', name, properties), minimizeWindow: () => ipcRenderer.invoke('win:minimize'),