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
122 changes: 98 additions & 24 deletions renderer/src/components/PlayerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -84,40 +86,92 @@ function VolumeButton({ volume }: { volume: number }) {
const [open, setOpen] = useState(false);
const [localVol, setLocalVol] = useState(volume);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const preMuteRef = useRef<number>(volume > 0 ? volume : 50);

// Sync incoming WS volume only when the user isn't actively dragging
const dragging = useRef(false);
useEffect(() => {
if (!dragging.current) setLocalVol(volume);
}, [volume]);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
setLocalVol(val);
const commitVolume = (val: number) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
getActiveProvider().setVolume(val);
dragging.current = false;
}, 150);
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div ref={wrapRef} className={styles.volWrap}>
<div
className={styles.volWrap}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
{open && (
<div className={styles.volPopover}>
<span className={styles.volPct}>{localVol}</span>
<button
className={styles.volStep}
onClick={() => step(+1)}
disabled={localVol >= 100}
title="Volume up"
aria-label="Volume up"
>
<ChevronUp size={14} />
</button>
<input
className={styles.volSliderV}
type="range"
Expand All @@ -129,12 +183,22 @@ function VolumeButton({ volume }: { volume: number }) {
}}
onChange={handleChange}
/>
<button
className={styles.volStep}
onClick={() => step(-1)}
disabled={localVol <= 0}
title="Volume down"
aria-label="Volume down"
>
<ChevronDown size={14} />
</button>
</div>
)}
<button
className={styles.ctrl}
onClick={() => setOpen((o) => !o)}
title="Volume"
onClick={toggleMute}
title={muted ? "Unmute" : "Mute"}
aria-label={muted ? "Unmute" : "Mute"}
>
<VolumeIconGlyph volume={localVol} />
</button>
Expand All @@ -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);

Expand Down Expand Up @@ -218,17 +282,27 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props)
)}
</div>
<div className={styles.trackInfo}>
<div
style={{
display: "flex",
alignItems: "center",
overflow: "hidden",
}}
>
<div className={styles.trackLine}>
<ScrollingText
text={`${displayTrack || "—"}${displayArtist ? ` - ${displayArtist}` : ""}`}
text={displayTrack || "—"}
className={styles.trackName}
/>
{displayArtist && (
<>
<span className={styles.trackSep}>{' – '}</span>
{artistItem ? (
<button
type="button"
className={styles.trackArtist}
onClick={() => openItem(artistItem)}
>
{displayArtist}
</button>
) : (
<span className={styles.trackArtist}>{displayArtist}</span>
)}
</>
)}
{isExplicit && <ExplicitBadge />}
</div>
<div className={styles.progressSection}>
Expand Down
44 changes: 31 additions & 13 deletions renderer/src/components/__tests__/PlayerBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────
Expand Down
7 changes: 6 additions & 1 deletion renderer/src/components/album/AlbumPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -120,7 +125,7 @@ export function AlbumPanel({ onAddToQueue }: Props) {
)}
{data && (
<div className={styles.metaLine}>
{[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 ')}
</div>
)}
<div className={styles.actions}>
Expand Down
58 changes: 49 additions & 9 deletions renderer/src/components/queue/QueueSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -79,6 +79,19 @@ export const QueueSidebar = forwardRef<QueueSidebarHandle, Props>(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]);

Expand Down Expand Up @@ -346,14 +359,41 @@ onClick={handleContentClick}
{selCount > 0 && (
<div className={styles.selBar}>
<span>{selCount} track{selCount !== 1 ? 's' : ''} selected</span>
<button className={styles.selDelBtn} onClick={async () => {
const indices = [...selected];
setItems(prev => prev.filter((_, i) => !selected.has(i)));
setSelected(new Set());
await getActiveProvider().removeFromQueue(indices).catch(() => { onRefresh(); onError('Failed to remove tracks'); });
}}>
Delete
</button>
<div className={styles.selActions}>
{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 (
<button
className={styles.selExpandBtn}
title={tooltip}
onClick={() => {
setSelected(block);
lastSelected.current = anchor;
}}
>
Select album
</button>
);
})()}
<button className={styles.selDelBtn} onClick={async () => {
const indices = [...selected];
setItems(prev => prev.filter((_, i) => !selected.has(i)));
setSelected(new Set());
await getActiveProvider().removeFromQueue(indices).catch(() => { onRefresh(); onError('Failed to remove tracks'); });
}}>
Delete
</button>
</div>
</div>
)}
</div>
Expand Down
Loading
Loading