diff --git a/src/App.tsx b/src/App.tsx index c9eb15d..23cb54e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import { } from "lucide-react"; import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo"; import { usePlayer } from "./hooks/usePlayer"; +import { buildArtistSummaries, type ArtistSummary } from "./lib/artists"; import { fetchAllPages, pollForLibrary } from "./lib/librarySync"; import { chooseMusicFolder, @@ -57,6 +58,11 @@ function Cover({ album, size = "medium" }: { album: Pick part[0]).join(""); + return ; +} + function IconButton({ label, children, onClick, active = false, pressed, className = "" }: { label: string; children: React.ReactNode; onClick?: () => void; active?: boolean; pressed?: boolean; className?: string }) { return ; } @@ -119,6 +125,13 @@ export default function App() { return needle ? libraryAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : libraryAlbums; }, [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 next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); @@ -427,6 +440,10 @@ export default function App() { )} + {view === "artists" ?
+
{query ? "Matching your search" : "The people behind your library"}

Artists

{visibleArtists.length} artists
+ {visibleArtists.length ?
{visibleArtists.map((artist) =>
)}
:

No artists found

Try another artist name.

} +
: <> {view !== "songs" &&
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}

{view === "favorites" ? "Loved records" : query ? "Albums" : "Recently added"}

{visibleAlbums.length ?
{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return

{album.title}

{album.artist} · {album.year || "Unknown year"}

; })}
:

No albums found

Try a title, artist, or track name.

} @@ -438,6 +455,7 @@ export default function App() { {queueAnnouncement.message}
} + } )} diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index 9149d3e..694171c 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -121,6 +121,23 @@ export function usePlayer(initialTracks: Track[]) { setIsPlaying(true); }, [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 audio = audioRef.current; if (!audio) return; @@ -230,6 +247,7 @@ export function usePlayer(initialTracks: Track[]) { isShuffled, repeatMode, play, + playCollection, toggle, seek, changeVolume, diff --git a/src/lib/artists.test.ts b/src/lib/artists.test.ts new file mode 100644 index 0000000..54f1c57 --- /dev/null +++ b/src/lib/artists.test.ts @@ -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); + }); +}); diff --git a/src/lib/artists.ts b/src/lib/artists.ts new file mode 100644 index 0000000..5400346 --- /dev/null +++ b/src/lib/artists.ts @@ -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; 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(), 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" })); +} diff --git a/src/styles.css b/src/styles.css index 1e34c6d..66ee693 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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--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-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); } @@ -297,6 +311,7 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib .hero-art .cover { width: 165px; } .hero-actions { position: relative; z-index: 4; } .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; } .section-heading { align-items: flex-end; } .section-heading h2 { font-size: 1.25rem; }