Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5bef0fa
fix: docked queue scroll-to-now-playing never fired on track/group ch…
May 5, 2026
50cef71
style: add gradient background to docked queue sidebar
May 5, 2026
d68855a
New Ranked View on Leader Board
BPMichon May 6, 2026
3651df1
Merge pull request #73 from TrueNorthIT/bm/some-random-tests
BPMichon May 6, 2026
9e70183
style: transparent docked queue background β€” inherits app gradient
May 6, 2026
d88a804
style: boost purple/blue radial gradient prominence in app shell
May 6, 2026
75c7f72
style: remove docked queue border-left β€” borderless floating scrollbar
May 6, 2026
dc53246
style: wrap docked queue window controls in a frosted pill
May 6, 2026
10decd6
style: fatter pill scrollbar on queue content area
May 6, 2026
d747cba
style: resize handle highlight follows mouse as a centred 200px pill
May 6, 2026
85e4848
style: resize handle glows and bulges with a purple lens on hover
May 6, 2026
8179740
style: remove outer box-shadow glow from resize handle
May 6, 2026
2cc386d
style: scrollbar β€” top gap, shorter thumb, wider hit box
May 6, 2026
77ffe97
style: scrollbar thumb taller (52px) and thinner (4px)
May 6, 2026
f4c1887
style: scrollbar 6px wide, 10px hit box, 124px thumb
May 6, 2026
435255b
style: scrollbar thumb capped at 20% of container height
May 6, 2026
8190648
style: scrollbar thumb max-height 300px
May 6, 2026
d084dde
style: global scrollbar β€” 10px hit box, 6px visual pill, top gap
May 6, 2026
6188bf5
feat: hide queue button in player bar and header when queue is docked
May 6, 2026
7f91be3
feat: centre player bar over main content area when queue is docked
May 6, 2026
122f49f
feat: centre top nav pill over main content area when queue is docked
May 6, 2026
3db71ea
feat: player bar and nav pill recentre live during queue resize drag
May 6, 2026
f4a081f
feat: queue max width derived from player bar width + padding
May 6, 2026
79e401b
feat: enforce minimum window width of 1144px (864 + min queue width)
May 6, 2026
4cc913b
style: resize handle glow emanates right only into queue
May 6, 2026
140df31
style: resize handle glow β€” larger (280x400) and more diffuse
May 6, 2026
4eb0b7e
style: Refine QueueSidebar resize handle appearance and usability
May 6, 2026
a6fb041
feat: scroll-sync panel-colour gradient and docked queue visual polish
May 6, 2026
70e400a
fix: restore main content scroll broken by routes wrapper div
May 6, 2026
50d3479
fix: simplify gradient β€” set directly on .body, no pseudo-element or JS
May 6, 2026
8f0de96
fix: prevent song change from clobbering body gradient colour
May 6, 2026
2df6473
Merge branch 'jp/docked-queue-fixes' into develop
May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/AlgorithWhiperer-notext.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/AlgorithmWhisperer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/Aux Cable Apprentice.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/BackgroundBopper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/PlaylistProphet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/Skip Button Survivor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/auxcableapprentice-notext.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/backgroundbooper-notext.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/playlistprophet-notext.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/skipbuttonsurvivor-notext.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 12 additions & 3 deletions renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,12 @@ function MainApp() {
const [queueMode, setQueueMode] = useState<'floating' | 'docked'>('floating');
const [queueDockedWidth, setQueueDockedWidth] = useState<number>(380);
const queueSidebarRef = useRef<QueueSidebarHandle>(null);
const shellRef = useRef<HTMLDivElement>(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(() => {});
Expand All @@ -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);
Expand Down Expand Up @@ -436,7 +440,11 @@ function MainApp() {
const splashReady = isAuthed && groups.length > 0 && !ytmLoading && !histLoading;

return (
<div className={styles.shell}>
<div
ref={shellRef}
className={styles.shell}
style={queueMode === 'docked' ? { '--docked-queue-w': `${queueDockedWidth}px` } as React.CSSProperties : undefined}
>
<Splash ready={splashReady} />
<TopNav
isAuthed={isAuthed}
Expand Down Expand Up @@ -510,6 +518,7 @@ function MainApp() {
onAddToQueue={handleAddToQueue}
dockedWidth={queueDockedWidth}
onResizeWidth={handleSetQueueDockedWidth}
onResizeWidthLive={handleResizeWidthLive}
/>
)}
</div>
Expand Down
148 changes: 141 additions & 7 deletions renderer/src/components/LeaderboardPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -19,6 +23,18 @@ const PERIODS: { value: StatsPeriod; label: string }[] = [

const MEDALS = ['πŸ₯‡', 'πŸ₯ˆ', 'πŸ₯‰'];

const QUEUEDLE_RANK_INFO: Array<{
key: Exclude<GameRankTierKey, 'provisional'>;
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([
{
Expand All @@ -33,12 +49,18 @@ function makeDragItem(t: StatsTrack) {

export function LeaderboardPanel() {
const [period, setPeriod] = useState<StatsPeriod>('week');
const [view, setView] = useState<'stats' | 'queuedle'>('stats');
const [selectedUser, setSelectedUser] = useState<string | null>(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 (
<div className={styles.page}>
Expand All @@ -55,23 +77,91 @@ export function LeaderboardPanel() {
{PERIODS.map((p) => (
<button
key={p.value}
className={`${styles.periodBtn}${period === p.value ? ' ' + styles.active : ''}`}
onClick={() => setPeriod(p.value)}
className={`${styles.periodBtn}${!isQueuedleView && period === p.value ? ' ' + styles.active : ''}`}
onClick={() => {
setView('stats');
setPeriod(p.value);
}}
>
{p.label}
</button>
))}
<button
className={`${styles.periodBtn}${isQueuedleView ? ' ' + styles.active : ''}`}
onClick={() => {
setSelectedUser(null);
setView('queuedle');
}}
>
Queuedle
</button>
</div>
<button className={styles.refreshBtn} onClick={() => refetch()} title="Refresh">
<button
className={styles.refreshBtn}
onClick={() => (isQueuedleView ? rankings.refetch() : refetch())}
title="Refresh"
>
β†Ί
</button>
<button
className={styles.infoBtn}
onClick={() => setRankInfoOpen(true)}
title="Rank info"
aria-label="Rank info"
>
<Info size={16} strokeWidth={2} />
</button>
</div>

{isLoading && <div className={styles.state}>Loading…</div>}
{(error || data?.error) && <div className={styles.state}>{data?.error ?? 'Failed to load stats'}</div>}
{isQueuedleView && rankings.isLoading && <div className={styles.state}>Loading Queuedle rankings…</div>}
{isQueuedleView && rankings.error && <div className={styles.state}>Failed to load Queuedle rankings</div>}
{!isQueuedleView && isLoading && <div className={styles.state}>Loading…</div>}
{!isQueuedleView && (error || data?.error) && (
<div className={styles.state}>{data?.error ?? 'Failed to load stats'}</div>
)}

{data && !data.error && !isLoading && (
<div className={styles.body}>
{isQueuedleView && !rankings.isLoading && !rankings.error && (
<div key="queuedle" className={styles.body}>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Queuedle all-time average</h2>
{queuedleRows.length === 0 ? (
<div className={styles.empty}>No Queuedle scores yet</div>
) : (
queuedleRows.slice(0, 25).map((r, i) => (
<div key={r.userName} className={styles.queuedleRow}>
<span className={styles.rank}>
{i < 3 ? MEDALS[i] : <span className={styles.rankNum}>{i + 1}</span>}
</span>
<span className={styles.userName}>{r.userName}</span>
<div className={styles.barWrap}>
<div
className={styles.bar}
style={{ width: `${Math.round((r.averageTotal / maxQueuedleAverage) * 100)}%` }}
/>
</div>
<span className={styles.queuedleGames}>
{r.gamesPlayed} {r.gamesPlayed === 1 ? 'game' : 'games'}
</span>
<span
className={`${styles.rankTier}${r.isProvisional ? ' ' + styles.rankTierProvisional : ''}`}
title={`${r.averagePercent.toFixed(0)}% average`}
data-tier={r.tierKey}
>
{getGameRankIcon(r.tierKey) && (
<img className={styles.rankTierIcon} src={getGameRankIcon(r.tierKey)!} alt="" loading="lazy" />
)}
{r.tierName}
</span>
<span className={styles.count}>{r.averageTotal.toFixed(1)}</span>
</div>
))
)}
</section>
</div>
)}

{!isQueuedleView && data && !data.error && !isLoading && (
<div key={`stats-${period}-${selectedUser ?? 'all'}`} className={styles.body}>
{/* ── Top queuers (leaderboard view only) ── */}
{!selectedUser && (
<section className={styles.section}>
Expand Down Expand Up @@ -288,6 +378,50 @@ export function LeaderboardPanel() {
</div>
</div>
)}

{rankInfoOpen && (
<div className={styles.rankInfoOverlay} onClick={() => setRankInfoOpen(false)}>
<div
className={styles.rankInfoDialog}
role="dialog"
aria-modal="true"
aria-labelledby="rank-info-title"
onClick={(e) => e.stopPropagation()}
>
<div className={styles.rankInfoHeader}>
<h2 id="rank-info-title" className={styles.rankInfoTitle}>
Queuedle Rank Tiers
</h2>
<button
className={styles.rankInfoClose}
onClick={() => setRankInfoOpen(false)}
aria-label="Close rank info"
>
<X size={16} strokeWidth={2} />
</button>
</div>
<p className={styles.rankInfoText}>
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.
</p>
<div className={styles.rankInfoList}>
{QUEUEDLE_RANK_INFO.map((tier) => {
const infoImage = getGameRankInfoImage(tier.key);
return (
<div key={tier.key} className={styles.rankInfoRow} data-tier={tier.key}>
{infoImage && <img className={styles.rankInfoImage} src={infoImage} alt="" loading="lazy" />}
<span className={styles.rankInfoName}>{tier.name}</span>
<span className={styles.rankInfoRange}>{tier.range}</span>
</div>
);
})}
</div>
<div className={styles.rankInfoFooter}>
Fewer than 3 played days = Provisional (no tier)
</div>
</div>
</div>
)}
</div>
);
}
8 changes: 5 additions & 3 deletions renderer/src/components/PlayerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,11 @@ export function PlayerBar({ isAuthed, playback, onToggleQueue, onShuffle, queueM
>
<MicVocal size={14} />
</button>
<button className={styles.ctrl} onClick={onToggleQueue} title={queueMode === 'docked' ? 'Jump to now playing' : 'Queue'}>
<List size={14} />
</button>
{queueMode !== 'docked' && (
<button className={styles.ctrl} onClick={onToggleQueue} title="Queue">
<List size={14} />
</button>
)}
<button
className={styles.ctrl}
onClick={() => window.sonos.openMiniPlayer()}
Expand Down
16 changes: 9 additions & 7 deletions renderer/src/components/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,15 @@ export function TopNav({
</button>
)}

<button
className={`${styles.iconBtn}${queueMode === 'floating' && queueOpen ? ' ' + styles.active : ''}`}
onClick={onToggleQueue}
title={queueMode === 'docked' ? 'Jump to now playing' : 'Queue'}
>
<List size={15} />
</button>
{queueMode !== 'docked' && (
<button
className={`${styles.iconBtn}${queueOpen ? ' ' + styles.active : ''}`}
onClick={onToggleQueue}
title="Queue"
>
<List size={15} />
</button>
)}
</div>
</nav>
</div>
Expand Down
Loading
Loading