Replace the Artists route fallback content with a searchable artist list, counts, artwork, and artist-specific playback.

Hop-State: A_06FNPER3B0F42JETD868VDG
Hop-Proposal: R_06FNPEQ1X5JW4SKNSA3856G
Hop-Task: T_06FNPDXDVREH0RSVM1731Q0
Hop-Attempt: AT_06FNPDXDVS3KPW8HKJPGAA8
This commit is contained in:
2026-07-13 04:27:01 -07:00
committed by Hop
parent 00832b4eda
commit 34af86bfc3
5 changed files with 111 additions and 0 deletions
+18
View File
@@ -32,6 +32,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo"; import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo";
import { usePlayer } from "./hooks/usePlayer"; import { usePlayer } from "./hooks/usePlayer";
import { buildArtistSummaries, type ArtistSummary } from "./lib/artists";
import { fetchAllPages, pollForLibrary } from "./lib/librarySync"; import { fetchAllPages, pollForLibrary } from "./lib/librarySync";
import { import {
chooseMusicFolder, chooseMusicFolder,
@@ -57,6 +58,11 @@ function Cover({ album, size = "medium" }: { album: Pick<Album, "title" | "artis
); );
} }
function ArtistPortrait({ artist }: { artist: ArtistSummary }) {
const initials = artist.name.split(/\s+/).slice(0, 2).map((part) => part[0]).join("");
return <span className={`artist-portrait cover--${artist.coverTone}`} aria-hidden="true">{artist.coverUrl ? <img src={artist.coverUrl} alt="" /> : <b>{initials}</b>}</span>;
}
function IconButton({ label, children, onClick, active = false, pressed, className = "" }: { label: string; children: React.ReactNode; onClick?: () => void; active?: boolean; pressed?: boolean; className?: string }) { function IconButton({ label, children, onClick, active = false, pressed, className = "" }: { label: string; children: React.ReactNode; onClick?: () => void; active?: boolean; pressed?: boolean; className?: string }) {
return <button type="button" className={`icon-button ${active ? "is-active" : ""} ${className}`} aria-label={label} aria-pressed={pressed ?? (active || undefined)} onClick={onClick}>{children}</button>; return <button type="button" className={`icon-button ${active ? "is-active" : ""} ${className}`} aria-label={label} aria-pressed={pressed ?? (active || undefined)} onClick={onClick}>{children}</button>;
} }
@@ -119,6 +125,13 @@ export default function App() {
return needle ? libraryAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : libraryAlbums; return needle ? libraryAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : libraryAlbums;
}, [libraryAlbums, query]); }, [libraryAlbums, query]);
const artists = useMemo(() => buildArtistSummaries(libraryTracks), [libraryTracks]);
const visibleArtists = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return needle ? artists.filter((artist) => artist.name.toLocaleLowerCase().includes(needle)) : artists;
}, [artists, query]);
const toggleFavorite = (id: string) => setFavorites((current) => { const toggleFavorite = (id: string) => setFavorites((current) => {
const next = new Set(current); const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id); if (next.has(id)) next.delete(id); else next.add(id);
@@ -427,6 +440,10 @@ export default function App() {
</section> </section>
)} )}
{view === "artists" ? <section className="content-section artist-section" aria-labelledby="artists-heading">
<div className="section-heading"><div><span className="eyebrow">{query ? "Matching your search" : "The people behind your library"}</span><h2 id="artists-heading">Artists</h2></div><span>{visibleArtists.length} artists</span></div>
{visibleArtists.length ? <div className="artist-list" role="list">{visibleArtists.map((artist) => <div role="listitem" key={artist.name.toLocaleLowerCase()}><button className="artist-row" type="button" aria-label={`Play music by ${artist.name}`} onClick={() => player.playCollection(artist.tracks)}><ArtistPortrait artist={artist} /><span className="artist-meta"><strong>{artist.name}</strong><small>{artist.albumCount} {artist.albumCount === 1 ? "album" : "albums"} · {artist.tracks.length} {artist.tracks.length === 1 ? "track" : "tracks"}</small></span><span className="artist-play" aria-hidden="true"><Play size={16} fill="currentColor" /></span></button></div>)}</div> : <div className="empty-state"><Search size={26} /><h3>No artists found</h3><p>Try another artist name.</p></div>}
</section> : <>
{view !== "songs" && <section className="content-section" aria-labelledby="albums-heading"> {view !== "songs" && <section className="content-section" aria-labelledby="albums-heading">
<div className="section-heading"><div><span className="eyebrow">{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}</span><h2 id="albums-heading">{view === "favorites" ? "Loved records" : query ? "Albums" : "Recently added"}</h2></div><button type="button" onClick={() => goTo("albums")}>View all <ChevronRight size={16} /></button></div> <div className="section-heading"><div><span className="eyebrow">{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}</span><h2 id="albums-heading">{view === "favorites" ? "Loved records" : query ? "Albums" : "Recently added"}</h2></div><button type="button" onClick={() => goTo("albums")}>View all <ChevronRight size={16} /></button></div>
{visibleAlbums.length ? <div className="album-grid">{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => firstTrack && player.play(firstTrack)}><Cover album={album} /><span className="cover-play"><Play size={20} fill="currentColor" /></span></button><div><h3>{album.title}</h3><p>{album.artist} · {album.year || "Unknown year"}</p></div><IconButton label={`More options for ${album.title}`}><Ellipsis size={18} /></IconButton></article>; })}</div> : <div className="empty-state"><Search size={26} /><h3>No albums found</h3><p>Try a title, artist, or track name.</p></div>} {visibleAlbums.length ? <div className="album-grid">{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => firstTrack && player.play(firstTrack)}><Cover album={album} /><span className="cover-play"><Play size={20} fill="currentColor" /></span></button><div><h3>{album.title}</h3><p>{album.artist} · {album.year || "Unknown year"}</p></div><IconButton label={`More options for ${album.title}`}><Ellipsis size={18} /></IconButton></article>; })}</div> : <div className="empty-state"><Search size={26} /><h3>No albums found</h3><p>Try a title, artist, or track name.</p></div>}
@@ -438,6 +455,7 @@ export default function App() {
<span className="sr-only" key={queueAnnouncement.key} role="status" aria-live="polite">{queueAnnouncement.message}</span> <span className="sr-only" key={queueAnnouncement.key} role="status" aria-live="polite">{queueAnnouncement.message}</span>
</section> </section>
</>} </>}
</>}
</div> </div>
)} )}
</main> </main>
+18
View File
@@ -121,6 +121,23 @@ export function usePlayer(initialTracks: Track[]) {
setIsPlaying(true); setIsPlaying(true);
}, [commitCurrent, commitQueue]); }, [commitCurrent, commitQueue]);
const playCollection = useCallback((items: Track[]) => {
const firstTrack = items[0];
if (!firstTrack) return;
historyRef.current = [];
const upcoming = shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1);
commitQueue(upcoming);
if (firstTrack.id === currentRef.current.id) {
void audioRef.current?.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
return;
}
commitCurrent(firstTrack);
setIsPlaying(true);
}, [commitCurrent, commitQueue]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
@@ -230,6 +247,7 @@ export function usePlayer(initialTracks: Track[]) {
isShuffled, isShuffled,
repeatMode, repeatMode,
play, play,
playCollection,
toggle, toggle,
seek, seek,
changeVolume, changeVolume,
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import type { Track } from "../types";
import { buildArtistSummaries } from "./artists";
const track = (id: string, artist: string, albumId: string): Track => ({
id,
title: id,
artist,
album: albumId,
albumId,
duration: 180,
coverTone: "dusk",
});
describe("buildArtistSummaries", () => {
it("groups artists case-insensitively and counts their albums and tracks", () => {
const artists = buildArtistSummaries([
track("song-1", "North Window", "album-1"),
track("song-2", "north window", "album-1"),
track("song-3", "North Window", "album-2"),
track("song-4", "Mara Venn", "album-3"),
]);
expect(artists.map((artist) => artist.name)).toEqual(["Mara Venn", "North Window"]);
expect(artists[1]).toMatchObject({ albumCount: 2 });
expect(artists[1].tracks).toHaveLength(3);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { CoverTone, Track } from "../types";
export interface ArtistSummary {
name: string;
albumCount: number;
tracks: Track[];
coverTone: CoverTone;
coverUrl?: string;
}
export function buildArtistSummaries(tracks: readonly Track[]): ArtistSummary[] {
const artists = new Map<string, { name: string; albumIds: Set<string>; tracks: Track[] }>();
tracks.forEach((track) => {
const name = track.artist.trim() || "Unknown artist";
const key = name.toLocaleLowerCase();
const existing = artists.get(key) ?? { name, albumIds: new Set<string>(), tracks: [] };
existing.albumIds.add(track.albumId || track.album);
existing.tracks.push(track);
artists.set(key, existing);
});
return [...artists.values()]
.map(({ name, albumIds, tracks: artistTracks }) => ({
name,
albumCount: albumIds.size,
tracks: artistTracks,
coverTone: artistTracks[0].coverTone,
coverUrl: artistTracks[0].coverUrl,
}))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
}
+15
View File
@@ -132,6 +132,20 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.cover--indigo { background: radial-gradient(circle at 34% 28%, oklch(0.69 0.12 247), transparent 18%), linear-gradient(140deg, oklch(0.4 0.13 261), oklch(0.18 0.04 270)); } .cover--indigo { background: radial-gradient(circle at 34% 28%, oklch(0.69 0.12 247), transparent 18%), linear-gradient(140deg, oklch(0.4 0.13 261), oklch(0.18 0.04 270)); }
.cover--sand { background: radial-gradient(circle at 68% 30%, oklch(0.86 0.065 84), transparent 22%), linear-gradient(145deg, oklch(0.67 0.08 74), oklch(0.29 0.045 53)); } .cover--sand { background: radial-gradient(circle at 68% 30%, oklch(0.86 0.065 84), transparent 22%), linear-gradient(145deg, oklch(0.67 0.08 74), oklch(0.29 0.045 53)); }
.artist-section { max-width: 1040px; }
.artist-list { display: grid; grid-template-columns: repeat(2, minmax(250px, 1fr)); column-gap: 34px; border-top: 1px solid var(--line); }
.artist-row { display: grid; width: 100%; min-width: 0; grid-template-columns: 58px minmax(0, 1fr) 32px; align-items: center; gap: 13px; padding: 12px 8px; border: 0; border-bottom: 1px solid oklch(0.28 0.012 275 / 0.58); border-radius: 9px; background: transparent; text-align: left; transition: color 160ms ease, background 160ms ease; }
.artist-row:hover, .artist-row:focus-visible { background: oklch(0.2 0.012 275 / 0.78); }
.artist-portrait { display: grid; width: 54px; height: 54px; overflow: hidden; place-items: center; border-radius: 50%; box-shadow: 0 7px 20px oklch(0.05 0.018 275 / 0.28); }
.artist-portrait img { width: 100%; height: 100%; object-fit: cover; }
.artist-portrait b { color: oklch(0.94 0.018 60 / 0.8); font-size: 0.78rem; letter-spacing: -0.03em; text-transform: uppercase; }
.artist-meta { min-width: 0; }
.artist-meta strong, .artist-meta small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.artist-meta strong { color: var(--ink); font-size: 0.82rem; font-weight: 600; }
.artist-meta small { margin-top: 4px; color: var(--faint); font-size: 0.67rem; }
.artist-play { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 50%; color: var(--muted); background: var(--surface-raised); transition: color 160ms ease, background 160ms ease, transform 160ms cubic-bezier(.22, 1, .36, 1); }
.artist-row:hover .artist-play, .artist-row:focus-visible .artist-play { color: oklch(0.17 0.012 275); background: var(--accent-bright); transform: scale(1.05); }
.track-list { overflow: hidden; border-top: 1px solid var(--line); } .track-list { overflow: hidden; border-top: 1px solid var(--line); }
.track-row { display: grid; grid-template-columns: minmax(245px, 1.5fr) minmax(130px, 1fr) 34px 48px 34px; min-height: 57px; align-items: center; border-bottom: 1px solid oklch(0.28 0.012 275 / 0.58); color: var(--muted); transition: background 140ms ease; } .track-row { display: grid; grid-template-columns: minmax(245px, 1.5fr) minmax(130px, 1fr) 34px 48px 34px; min-height: 57px; align-items: center; border-bottom: 1px solid oklch(0.28 0.012 275 / 0.58); color: var(--muted); transition: background 140ms ease; }
.track-row:hover, .track-row:focus-within { background: oklch(0.2 0.012 275 / 0.78); } .track-row:hover, .track-row:focus-within { background: oklch(0.2 0.012 275 / 0.78); }
@@ -297,6 +311,7 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib
.hero-art .cover { width: 165px; } .hero-art .cover { width: 165px; }
.hero-actions { position: relative; z-index: 4; } .hero-actions { position: relative; z-index: 4; }
.album-grid { grid-template-columns: repeat(2, minmax(100px, 1fr)); gap: 19px 13px; } .album-grid { grid-template-columns: repeat(2, minmax(100px, 1fr)); gap: 19px 13px; }
.artist-list { grid-template-columns: 1fr; }
.album-tile:nth-child(n + 5) { display: block; } .album-tile:nth-child(n + 5) { display: block; }
.section-heading { align-items: flex-end; } .section-heading { align-items: flex-end; }
.section-heading h2 { font-size: 1.25rem; } .section-heading h2 { font-size: 1.25rem; }