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
2,488 changes: 2,488 additions & 0 deletions examples/artist.json

Large diffs are not rendered by default.

621 changes: 621 additions & 0 deletions examples/search.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ useEffect(() => {
}
/>
<Route path="/album/:id" element={<AlbumPanel onAddToQueue={handleAddToQueue} />} />
<Route path="/artist/:id" element={<ArtistPanel onAddToQueue={handleAddToQueue} />} />
<Route path="/artist/:id" element={<ArtistPanel onAddToQueue={handleAddToQueue} currentTrackName={playback.trackName} isPlaybackActive={playback.isPlaying} />} />
<Route path="/container/:id" element={<ContainerPanel onAddToQueue={handleAddToQueue} />} />
<Route path="/leaderboard" element={<LeaderboardPanel />} />
<Route path="/queuedle" element={<QueuedlePanel />} />
Expand Down
94 changes: 84 additions & 10 deletions renderer/src/components/artist/ArtistPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,42 @@ import { useOpenItem } from '../../hooks/useOpenItem';
import { resolveArtistParams } from '../../lib/itemHelpers';
import { TopSongRow } from './TopSongRow';
import { LatestReleaseCard } from './LatestReleaseCard';
import { RadioCard } from './RadioCard';
import { ArtistAlbumCard } from './ArtistAlbumCard';
import type { SonosItem } from '../../types/sonos';
import styles from '../../styles/ArtistPanel.module.css';

function domToText(node: GeniusDomNode): string {
if (typeof node === 'string') return node;
return (node.children ?? []).map(domToText).join('');
}

function extractBio(description: GeniusDomNode | null): string {
if (!description || typeof description === 'string') return '';
const root = description as { tag: string; children?: GeniusDomNode[] };
const paras = (root.children ?? []).filter(
c => typeof c !== 'string' && (c as { tag: string }).tag === 'p',
);
return paras
.map(p => domToText(p as GeniusDomNode).trim())
.filter(Boolean)
.join('\n\n');
}

interface Props {
onAddToQueue: (item: SonosItem) => void;
currentTrackName: string;
isPlaybackActive: boolean;
}

export function ArtistPanel({ onAddToQueue }: Props) {
export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive }: Props) {
const { state } = useLocation();
const item = (state as { item?: SonosItem } | null)?.item;
const openItem = useOpenItem();

const { artistId, serviceId, accountId, defaults, name: fallbackName } =
item ? resolveArtistParams(item) : { artistId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined, name: undefined };
item
? resolveArtistParams(item)
: { artistId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined, name: undefined };

const { data, isLoading } = useArtistBrowse(artistId, serviceId, accountId, defaults);

Expand All @@ -44,13 +64,27 @@ export function ArtistPanel({ onAddToQueue }: Props) {
if (e.shiftKey && lastSelected.current !== null) {
const lo = Math.min(lastSelected.current, index);
const hi = Math.max(lastSelected.current, index);
setSelected(prev => { const next = new Set(prev); for (let i = lo; i <= hi; i++) next.add(i); return next; });
setSelected(prev => {
const next = new Set(prev);
for (let i = lo; i <= hi; i++) next.add(i);
return next;
});
} else if (e.ctrlKey || e.metaKey) {
setSelected(prev => { const next = new Set(prev); if (next.has(index)) next.delete(index); else next.add(index); return next; });
setSelected(prev => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
lastSelected.current = index;
} else {
if (selected.size === 1 && selected.has(index)) { setSelected(new Set()); lastSelected.current = null; }
else { setSelected(new Set([index])); lastSelected.current = index; }
if (selected.size === 1 && selected.has(index)) {
setSelected(new Set());
lastSelected.current = null;
} else {
setSelected(new Set([index]));
lastSelected.current = index;
}
}
}

Expand All @@ -75,9 +109,11 @@ export function ArtistPanel({ onAddToQueue }: Props) {
const cachedArt = useImage(imageUrl);
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;

const genius = data?.genius ?? null;
const blurb = genius ? extractBio(genius.description) : '';
const altNames = genius?.alternateNames?.filter(Boolean).slice(0, 4) ?? [];
if (!item) return null;

return (
Expand All @@ -99,6 +135,9 @@ export function ArtistPanel({ onAddToQueue }: Props) {
</div>
<div className={styles.headerInfo}>
<div className={styles.artistName}>{name}</div>
{altNames.length > 0 && (
<div className={styles.altNames}>{altNames.join(' · ')}</div>
)}
</div>
</div>
</div>
Expand All @@ -119,7 +158,10 @@ export function ArtistPanel({ onAddToQueue }: Props) {
<div className={styles.topSongsCol}>
{data && data.topSongs.length > 0 && (
<>
<button className={styles.sectionTitleBtn} onClick={() => { setShowAllSongs(s => !s); setSelected(new Set()); }}>
<button
className={styles.sectionTitleBtn}
onClick={() => { setShowAllSongs(s => !s); setSelected(new Set()); }}
>
Top Songs <span className={styles.sectionChevron}>{showAllSongs ? '∨' : '›'}</span>
</button>
{visibleSongs.map((track, i) => (
Expand All @@ -128,6 +170,8 @@ export function ArtistPanel({ onAddToQueue }: Props) {
track={track}
index={i}
isSelected={selected.has(i)}
isCurrentTrack={track.title === currentTrackName}
isPlaybackActive={isPlaybackActive}
onAdd={onAddToQueue}
onClick={handleTrackClick}
onDragStart={handleDragStart}
Expand All @@ -136,9 +180,39 @@ export function ArtistPanel({ onAddToQueue }: Props) {
</>
)}
</div>

<div className={styles.sideCol}>
{blurb && (
<div className={styles.aboutSection}>
<div className={styles.sectionTitle}>About</div>
{blurb.split('\n\n').map((para, i) => (
<p key={i} className={styles.aboutText}>{para}</p>
))}
</div>
)}
{(genius?.instagram || genius?.twitter) && (
<div className={styles.socialSection}>
<div className={styles.socialIcons}>
{genius.instagram && (
<button className={styles.socialIconBtn} title={`@${genius.instagram}`} onClick={() => window.sonos.openExternal(`https://instagram.com/${genius.instagram}`)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"/>
<circle cx="12" cy="12" r="4"/>
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/>
</svg>
</button>
)}
{genius.twitter && (
<button className={styles.socialIconBtn} title={`@${genius.twitter}`} onClick={() => window.sonos.openExternal(`https://twitter.com/${genius.twitter}`)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.261 5.635 5.903-5.635zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
)}
</div>
</div>
)}
{latestAlbum && <LatestReleaseCard album={latestAlbum} onOpen={openItem} />}
{artistRadio && <RadioCard item={artistRadio} artUrl={cachedArt} onOpen={openItem} />}
</div>
</>
)}
Expand Down
16 changes: 13 additions & 3 deletions renderer/src/components/artist/TopSongRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ interface Props {
track: AlbumTrack;
index: number;
isSelected: boolean;
isCurrentTrack?: boolean;
isPlaybackActive?: boolean;
onAdd: (item: SonosItem) => void;
onClick: (index: number, e: React.MouseEvent) => void;
onDragStart: (index: number, e: React.DragEvent) => void;
}

export function TopSongRow({ track, index, isSelected, onAdd, onClick, onDragStart }: Props) {
export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybackActive, onAdd, onClick, onDragStart }: Props) {
const art = useImage(track.artUrl);
const subtitle = (track.raw as Record<string, unknown>)?.['subtitle'] as string | undefined;
return (
Expand All @@ -24,12 +26,20 @@ export function TopSongRow({ track, index, isSelected, onAdd, onClick, onDragSta
onClick={e => onClick(index, e)}
onDragStart={e => onDragStart(index, e)}
>
<span className={styles.topSongNum}>{index + 1}</span>
{isCurrentTrack ? (
<div className={`${styles.waveform}${!isPlaybackActive ? ' ' + styles.waveformPaused : ''}`}>
<div className={styles.waveformBar} />
<div className={styles.waveformBar} />
<div className={styles.waveformBar} />
</div>
) : (
<span className={styles.topSongNum}>{index + 1}</span>
)}
<div className={styles.topSongArt}>
{art ? <img src={art} alt="" /> : <div className={styles.topSongArtPh} />}
</div>
<div className={styles.topSongInfo}>
<span className={styles.topSongName}>
<span className={`${styles.topSongName}${isCurrentTrack ? ' ' + styles.topSongNameActive : ''}`}>
{track.title}
{track.explicit && <ExplicitBadge />}
</span>
Expand Down
18 changes: 9 additions & 9 deletions renderer/src/components/artist/__tests__/ArtistPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ function makeData(overrides = {}) {
};
}

const defaultProps = { onAddToQueue: vi.fn(), currentTrackName: '', isPlaybackActive: false };

beforeEach(() => {
vi.clearAllMocks();
mockUseLocation.mockReturnValue({ state: { item: artistItem } });
Expand All @@ -57,18 +59,18 @@ beforeEach(() => {
describe('ArtistPanel', () => {
it('returns nothing when no item in state', () => {
mockUseLocation.mockReturnValue({ state: null });
const { container } = render(<ArtistPanel onAddToQueue={vi.fn()} />);
const { container } = render(<ArtistPanel {...defaultProps} />);
expect(container.firstChild).toBeNull();
});

it('shows artist name from data', () => {
render(<ArtistPanel onAddToQueue={vi.fn()} />);
render(<ArtistPanel {...defaultProps} />);
expect(screen.getByText('The Beatles')).toBeInTheDocument();
});

it('shows loading skeletons while loading', () => {
mockUseArtistBrowse.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<ArtistPanel onAddToQueue={vi.fn()} />);
const { container } = render(<ArtistPanel {...defaultProps} />);
expect(container.querySelectorAll('[class*="skeletonRow"]').length).toBeGreaterThan(0);
});

Expand All @@ -78,7 +80,7 @@ describe('ArtistPanel', () => {
{ name: 'Let It Be', id: { objectId: 'trk-2' } },
];
mockUseArtistBrowse.mockReturnValue({ data: makeData({ topSongs }), isLoading: false });
render(<ArtistPanel onAddToQueue={vi.fn()} />);
render(<ArtistPanel {...defaultProps} />);
expect(screen.getByText('Come Together')).toBeInTheDocument();
expect(screen.getByText('Let It Be')).toBeInTheDocument();
});
Expand All @@ -90,11 +92,9 @@ describe('ArtistPanel', () => {
}));
mockUseArtistBrowse.mockReturnValue({ data: makeData({ topSongs }), isLoading: false });
const user = userEvent.setup();
render(<ArtistPanel onAddToQueue={vi.fn()} />);
// Initially shows first 10
render(<ArtistPanel {...defaultProps} />);
expect(screen.queryByText('Song 11')).not.toBeInTheDocument();
await user.click(screen.getByText(/Top Songs/));
// After click shows all 12
expect(screen.getByText('Song 11')).toBeInTheDocument();
});

Expand All @@ -104,15 +104,15 @@ describe('ArtistPanel', () => {
{ title: 'Album Two', id: { objectId: 'alb-2' } },
];
mockUseArtistBrowse.mockReturnValue({ data: makeData({ albums }), isLoading: false });
render(<ArtistPanel onAddToQueue={vi.fn()} />);
render(<ArtistPanel {...defaultProps} />);
expect(screen.getByText('Albums')).toBeInTheDocument();
expect(screen.getAllByText('Album Two').length).toBeGreaterThan(0);
});

it('does not render albums section when only one album', () => {
const albums = [{ title: 'Abbey Road', id: { objectId: 'alb-1' } }];
mockUseArtistBrowse.mockReturnValue({ data: makeData({ albums }), isLoading: false });
render(<ArtistPanel onAddToQueue={vi.fn()} />);
render(<ArtistPanel {...defaultProps} />);
expect(screen.queryByText('Albums')).not.toBeInTheDocument();
});
});
33 changes: 19 additions & 14 deletions renderer/src/hooks/useArtistBrowse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ export interface ArtistData {
albums: SonosItem[];
playlists: SonosItem[]; // Artist Shuffle, Artist Radio (no Top Songs)
topSongs: AlbumTrack[];
genius: GeniusArtistInfo | null;
}

function parseArtist(data: ArtistResponse): { parsed: Omit<ArtistData, 'topSongs'>; topSongsItem: SonosItem | null } {
function parseArtist(data: ArtistResponse): { parsed: Omit<ArtistData, 'topSongs' | 'genius'>; topSongsItem: SonosItem | null } {
const name = data.title ?? '';
const imageUrl = data.images?.tile1x1 ?? null;
const allItems = (data.sections?.items?.[0]?.items ?? []) as unknown as SonosItem[];
Expand Down Expand Up @@ -42,21 +43,25 @@ export function artistQueryOptions(

const { parsed, topSongsItem } = parseArtist(r.data as ArtistResponse);

let topSongs: AlbumTrack[] = [];
if (topSongsItem) {
const fetchTopSongs = async (): Promise<AlbumTrack[]> => {
if (!topSongsItem) return [];
const rid = topSongsItem.resource?.id as SonosItemId | undefined;
if (rid?.objectId && rid?.serviceId && rid?.accountId) {
const pr = await api.browse.playlist(rid.objectId, {
serviceId: rid.serviceId,
accountId: rid.accountId,
defaults: topSongsItem.resource?.defaults as string | undefined,
muse2: true,
});
if (!pr.error) topSongs = parsePlaylistTracks(pr.data);
}
}
if (!rid?.objectId || !rid?.serviceId || !rid?.accountId) return [];
const pr = await api.browse.playlist(rid.objectId, {
serviceId: rid.serviceId,
accountId: rid.accountId,
defaults: topSongsItem.resource?.defaults as string | undefined,
muse2: true,
});
return pr.error ? [] : parsePlaylistTracks(pr.data);
};

return { ...parsed, topSongs };
const [topSongs, genius] = await Promise.all([
fetchTopSongs(),
window.sonos.geniusArtist(parsed.name),
]);

return { ...parsed, topSongs, genius };
},
staleTime: Infinity,
gcTime: 60 * 60 * 1000,
Expand Down
Loading
Loading