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:
+18
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }));
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user