Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,5 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }}
GENIUS_ACCESS_TOKEN: ${{ secrets.GENIUS_ACCESS_TOKEN }}
VITE_GITHUB_PAT: ${{ secrets.VITE_GITHUB_PAT }}
60 changes: 6 additions & 54 deletions renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,9 @@ function MainApp() {
reloadQueue();
}, [playback.queueVersion, reloadQueue]);

const [queueOpen, setQueueOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [changelogOpen, setChangelogOpen] = useState(false);
const [displayName, setDisplayName] = useState<string | null | undefined>(undefined); // undefined = not yet loaded
const [queueMode, setQueueMode] = useState<'floating' | 'docked'>('floating');
const [queueDockedWidth, setQueueDockedWidth] = useState<number>(380);
const queueSidebarRef = useRef<QueueSidebarHandle>(null);
const shellRef = useRef<HTMLDivElement>(null);
Expand All @@ -96,39 +94,24 @@ function MainApp() {

useEffect(() => {
window.sonos.getDisplayName().then(setDisplayName);
window.sonos.getQueueMode().then(setQueueMode).catch(() => {});
window.sonos.getQueueDockedWidth().then(setQueueDockedWidth).catch(() => {});
}, []);

useEffect(() => {
if (queueMode !== 'docked') return;
function clamp() {
const max = window.innerWidth - (800 + 64);
setQueueDockedWidth((w) => (w > max ? max : w));
}
window.addEventListener('resize', clamp);
clamp();
return () => window.removeEventListener('resize', clamp);
}, [queueMode]);

const handleSetQueueMode = useCallback((mode: 'floating' | 'docked') => {
setQueueMode(mode);
window.sonos.setQueueMode(mode).catch(() => {});
}, []);

const handleSetQueueDockedWidth = useCallback((width: number) => {
setQueueDockedWidth(width);
window.sonos.setQueueDockedWidth(width).catch(() => {});
}, []);

const handleQueueButton = useCallback(() => {
if (queueMode === 'docked') {
queueSidebarRef.current?.scrollToNowPlaying();
} else {
setQueueOpen((o) => !o);
}
}, [queueMode]);

const splashReadyRef = useRef(false);
useEffect(() => {
const splashReady = isAuthed && groups.length > 0;
Expand Down Expand Up @@ -443,25 +426,21 @@ useEffect(() => {
<div
ref={shellRef}
className={styles.shell}
style={queueMode === 'docked' ? { '--docked-queue-w': `${queueDockedWidth}px` } as React.CSSProperties : undefined}
style={{ '--docked-queue-w': `${queueDockedWidth}px` } as React.CSSProperties}
>
<Splash ready={splashReady} />
<TopNav
isAuthed={isAuthed}
groups={groups}
activeGroupId={activeGroupId}
onGroupChange={handleGroupChange}
queueOpen={queueOpen}
onToggleQueue={handleQueueButton}
onResync={() => window.sonos.resync()}
displayName={displayName}
onSaveName={(name) => {
window.sonos.setDisplayName(name).catch(() => {});
setDisplayName(name);
}}
onChangelogOpen={() => setChangelogOpen(true)}
queueMode={queueMode}
onSetQueueMode={handleSetQueueMode}
/>
<div className={styles.body}>
<Routes>
Expand Down Expand Up @@ -491,42 +470,15 @@ useEffect(() => {
/>
}
/>
<Route path="/album/:id" element={<AlbumPanel onAddToQueue={handleAddToQueue} queueOpen={queueOpen || queueMode === 'docked'} />} />
<Route path="/album/:id" element={<AlbumPanel onAddToQueue={handleAddToQueue} />} />
<Route path="/artist/:id" element={<ArtistPanel onAddToQueue={handleAddToQueue} />} />
<Route path="/container/:id" element={<ContainerPanel onAddToQueue={handleAddToQueue} />} />
<Route path="/leaderboard" element={<LeaderboardPanel />} />
<Route path="/queuedle" element={<QueuedlePanel />} />
<Route path="/lyrics" element={<LyricsPanel playback={playback} />} />
</Routes>
{queueMode === 'docked' && (
<QueueSidebar
ref={queueSidebarRef}
mode="docked"
open
items={queueItems}
setItems={setQueueItems}
isLoading={queueLoading}
error={queueError}
currentObjectId={playback.currentObjectId}
currentQueueItemId={playback.queueItemId}
positionMs={playback.positionMs}
currentTrackDurationMs={playback.durationMs}
groupName={groups.find(g => g.id === activeGroupId)?.name ?? null}
onClose={() => {}}
onRefresh={reloadQueue}
onError={showToast}
onAddToQueue={handleAddToQueue}
dockedWidth={queueDockedWidth}
onResizeWidth={handleSetQueueDockedWidth}
onResizeWidthLive={handleResizeWidthLive}
/>
)}
</div>
{queueMode === 'floating' && (
<QueueSidebar
ref={queueSidebarRef}
mode="floating"
open={queueOpen}
items={queueItems}
setItems={setQueueItems}
isLoading={queueLoading}
Expand All @@ -536,18 +488,18 @@ useEffect(() => {
positionMs={playback.positionMs}
currentTrackDurationMs={playback.durationMs}
groupName={groups.find(g => g.id === activeGroupId)?.name ?? null}
onClose={() => setQueueOpen(false)}
onRefresh={reloadQueue}
onError={showToast}
onAddToQueue={handleAddToQueue}
dockedWidth={queueDockedWidth}
onResizeWidth={handleSetQueueDockedWidth}
onResizeWidthLive={handleResizeWidthLive}
/>
)}
</div>
<PlayerBar
isAuthed={isAuthed}
playback={playback}
onToggleQueue={handleQueueButton}
onShuffle={reloadQueue}
queueMode={queueMode}
/>
{toastMsg && <div className={styles.toast}>{toastMsg}</div>}
{displayName === null && (
Expand Down
19 changes: 8 additions & 11 deletions renderer/src/components/HomePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ export async function fetchYtmSections(): Promise<YtmSections> {
const rootItems = (stack?.['items'] ?? []) as SonosItem[];

const find = (title: string) => rootItems.find((i) => (i.title ?? i.name) === title);
const homeItem = find('Home');
const homeItem = find('Home');
const newReleasesItem = find('New releases');
const chartsItem = find('Charts');
const supermixItem = find('My Supermix');
const chartsItem = find('Charts');
const supermixItem = find('My Supermix');

const browseContainer = async (item: SonosItem | undefined): Promise<SonosItem[]> => {
if (!item) return [];
Expand All @@ -72,7 +72,7 @@ export async function fetchYtmSections(): Promise<YtmSections> {

return {
forYou: supermixItem
? [{ ...supermixItem, images: [] as { url: string }[], imageUrl: '/icon.png' }, ...homeItems]
? [{ ...supermixItem, images: [] as { url: string }[], imageUrl: '../../public/icon.png' }, ...homeItems]
: homeItems,
newReleases: newItems,
charts: chartItems,
Expand All @@ -81,11 +81,11 @@ export async function fetchYtmSections(): Promise<YtmSections> {

export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, history, histLoading }: Props) {
const queryClient = useQueryClient();
const location = useLocation();
const location = useLocation();
const [searchParams] = useSearchParams();
const openItem = useOpenItem();
const openItem = useOpenItem();

const view = location.pathname === '/search' ? 'search' : 'home';
const view = location.pathname === '/search' ? 'search' : 'home';
const activeSearch = searchParams.get('q') ?? '';

const { data: searchResults = [], isFetching: searchLoading } = useQuery({
Expand Down Expand Up @@ -122,10 +122,7 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, history, hi
{searchLoading ? (
<div className={styles.msg}>Searching…</div>
) : (
<SearchResults
results={searchResults}
onAddToQueue={onAddToQueue}
/>
<SearchResults results={searchResults} onAddToQueue={onAddToQueue} />
)}
</div>
);
Expand Down
1 change: 1 addition & 0 deletions renderer/src/components/LyricsPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
display: grid;
grid-template-columns: 38% 62%;
min-height: 0;
margin-right: var(--docked-queue-w, 0px);
}

/* ── Left panel ── */
Expand Down
10 changes: 1 addition & 9 deletions renderer/src/components/PlayerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
Volume1,
Volume2,
VolumeX,
List,
Music,
PictureInPicture2,
MicVocal,
Expand All @@ -27,9 +26,7 @@ import styles from "../styles/PlayerBar.module.css";
interface Props {
isAuthed: boolean;
playback: PlaybackState;
onToggleQueue: () => void;
onShuffle: () => void;
queueMode?: 'floating' | 'docked';
}

function ScrollingText({
Expand Down Expand Up @@ -142,7 +139,7 @@ function VolumeButton({ volume }: { volume: number }) {
);
}

export function PlayerBar({ isAuthed, playback, onToggleQueue, onShuffle, queueMode }: Props) {
export function PlayerBar({ isAuthed, playback, onShuffle }: Props) {
const navigate = useNavigate();
const { pathname } = useLocation();
const lyricsActive = pathname === '/lyrics';
Expand Down Expand Up @@ -305,11 +302,6 @@ export function PlayerBar({ isAuthed, playback, onToggleQueue, onShuffle, queueM
>
<MicVocal 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
40 changes: 0 additions & 40 deletions renderer/src/components/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
Search,
X,
User,
List,
RefreshCw,
Gamepad2,
Lightbulb,
Expand All @@ -17,36 +16,27 @@ import {
} from 'lucide-react';
import type { NormalizedGroup } from '../types/provider';
import styles from '../styles/TopNav.module.css';
import { WindowControls } from './WindowControls';

interface Props {
isAuthed: boolean;
groups: NormalizedGroup[];
activeGroupId: string | null;
onGroupChange: (groupId: string) => void;
queueOpen: boolean;
onToggleQueue: () => void;
onResync: () => void;
displayName: string | null | undefined;
onSaveName: (name: string) => void;
onChangelogOpen: () => void;
queueMode: 'floating' | 'docked';
onSetQueueMode: (mode: 'floating' | 'docked') => void;
}

export function TopNav({
isAuthed,
groups,
activeGroupId,
onGroupChange,
queueOpen,
onToggleQueue,
onResync,
displayName,
onSaveName,
onChangelogOpen,
queueMode,
onSetQueueMode,
}: Props) {
const navigate = useNavigate();
const location = useLocation();
Expand Down Expand Up @@ -265,21 +255,6 @@ export function TopNav({
>
Save
</button>
<div className={styles.namePopoverLabel}>Queue display</div>
<div className={styles.queueModeToggle}>
<button
className={`${styles.queueModeBtn}${queueMode === 'floating' ? ' ' + styles.queueModeBtnActive : ''}`}
onClick={() => onSetQueueMode('floating')}
>
Floating
</button>
<button
className={`${styles.queueModeBtn}${queueMode === 'docked' ? ' ' + styles.queueModeBtnActive : ''}`}
onClick={() => onSetQueueMode('docked')}
>
Docked
</button>
</div>
<div className={styles.versionRow}>
{appVersion && <div className={styles.appVersion}>v{appVersion}</div>}
</div>
Expand All @@ -306,25 +281,10 @@ export function TopNav({
</button>
)}

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

{/* Window controls — fixed top-right when queue is floating; moved into the docked sidebar otherwise */}
{queueMode === 'floating' && (
<div className={styles.windowPill}>
<WindowControls />
</div>
)}
</>
);
}
11 changes: 1 addition & 10 deletions renderer/src/components/__tests__/PlayerBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,14 @@ function makeNowPlaying(overrides: Partial<ReturnType<typeof useNowPlaying>> = {
function setup(
playbackOverrides: Partial<PlaybackState> = {},
nowPlayingOverrides: Partial<ReturnType<typeof useNowPlaying>> = {},
extraProps: { isAuthed?: boolean; onToggleQueue?: () => void; onShuffle?: () => void } = {}
extraProps: { isAuthed?: boolean; onShuffle?: () => void } = {}
) {
vi.mocked(useNowPlaying).mockReturnValue(makeNowPlaying(nowPlayingOverrides) as ReturnType<typeof useNowPlaying>);

const user = userEvent.setup();
const props = {
isAuthed: true,
playback: makePlayback(playbackOverrides),
onToggleQueue: vi.fn(),
onShuffle: vi.fn(),
...extraProps,
};
Expand Down Expand Up @@ -127,7 +126,6 @@ describe('PlayerBar — visibility', () => {
<PlayerBar
isAuthed={true}
playback={makePlayback()}
onToggleQueue={vi.fn()}
onShuffle={vi.fn()}
/>
</QueryClientProvider>
Expand Down Expand Up @@ -333,13 +331,6 @@ describe('PlayerBar — volume button', () => {
// ─── right-side controls ─────────────────────────────────────────────────────

describe('PlayerBar — right controls', () => {
it('Queue button calls onToggleQueue', async () => {
const onToggleQueue = vi.fn();
const { user } = setup({}, {}, { onToggleQueue });
await user.click(screen.getByTitle('Queue'));
expect(onToggleQueue).toHaveBeenCalled();
});

it('Mini player button calls openMiniPlayer', async () => {
const { user } = setup();
await user.click(screen.getByTitle('Mini player'));
Expand Down
Loading
Loading