diff --git a/src/App.test.tsx b/src/App.test.tsx index e5b4020..d7154f3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const managedServer = vi.hoisted(() => ({ @@ -39,6 +39,13 @@ function deferred() { return { promise, resolve }; } +async function renderDemoApp() { + managedServer.getManagedServerStatus.mockResolvedValue({ running: false, url: "http://127.0.0.1:4533", lanUrls: [], version: "0.61.2" }); + managedServer.getSavedCredentials.mockResolvedValue({}); + render(); + await screen.findByText("Let it find you again."); +} + describe("App bootstrap", () => { beforeAll(() => vi.stubGlobal("Audio", AudioMock)); afterAll(() => vi.unstubAllGlobals()); @@ -83,4 +90,29 @@ describe("App bootstrap", () => { expect(await screen.findByText("Let it find you again.")).not.toBeNull(); }); + + it("defaults Albums and Songs to A–Z and remembers reordered views", async () => { + await renderDemoApp(); + + fireEvent.click(screen.getByRole("button", { name: "Albums" })); + const albumSort = screen.getByLabelText("Sort albums") as HTMLSelectElement; + expect(albumSort.value).toBe("title-asc"); + expect(screen.getAllByRole("heading", { level: 3 })[0].textContent).toBe("A Quiet Current"); + + fireEvent.change(albumSort, { target: { value: "title-desc" } }); + expect(screen.getAllByRole("heading", { level: 3 })[0].textContent).toBe("Soft Geometry"); + expect(localStorage.getItem("resonant.albumSort")).toBe("title-desc"); + + fireEvent.click(screen.getByRole("button", { name: "Songs" })); + const songSort = screen.getByLabelText("Sort songs") as HTMLSelectElement; + const songTitles = () => screen.getAllByRole("listitem") + .map((item) => item.querySelector(".track-main strong")?.textContent) + .filter(Boolean); + expect(songSort.value).toBe("title-asc"); + expect(songTitles()[0]).toBe("Antenna Weather"); + + fireEvent.change(songSort, { target: { value: "title-desc" } }); + expect(songTitles()[0]).toBe("Violet Hours"); + expect(localStorage.getItem("resonant.trackSort")).toBe("title-desc"); + }); }); diff --git a/src/App.tsx b/src/App.tsx index 56ed898..9eb407b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { Album as AlbumIcon, AlertCircle, + ArrowUpDown, AudioLines, Check, ChevronRight, @@ -34,7 +35,17 @@ import { import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo"; import { usePlayer } from "./hooks/usePlayer"; import { buildArtistSummaries, type ArtistSummary } from "./lib/artists"; -import { orderAlbumsByRecentlyAdded, orderAlbumTracks, orderTracksByPlayCount, pickRandomTrack, selectHeavyRotation } from "./lib/libraryOrdering"; +import { + orderAlbumsByRecentlyAdded, + orderAlbumTracks, + orderTracksByPlayCount, + pickRandomTrack, + selectHeavyRotation, + sortAlbums, + sortTracks, + type AlbumSortMode, + type TrackSortMode, +} from "./lib/libraryOrdering"; import { fetchAllPages, pollForLibrary } from "./lib/librarySync"; import { chooseMusicFolder, @@ -52,6 +63,34 @@ import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } fr const tones: CoverTone[] = ["ember", "moss", "dusk", "clay", "indigo", "sand"]; const queuePreviewLimit = 100; const demoFavoritesKey = "resonant.demoFavorites"; +const albumSortKey = "resonant.albumSort"; +const trackSortKey = "resonant.trackSort"; +const albumSortOptions: Array<{ value: AlbumSortMode; label: string }> = [ + { value: "title-asc", label: "Title (A–Z)" }, + { value: "title-desc", label: "Title (Z–A)" }, + { value: "artist-asc", label: "Artist (A–Z)" }, + { value: "artist-desc", label: "Artist (Z–A)" }, + { value: "added-desc", label: "Recently added" }, + { value: "added-asc", label: "Oldest added" }, + { value: "year-desc", label: "Newest release" }, + { value: "year-asc", label: "Oldest release" }, +]; +const trackSortOptions: Array<{ value: TrackSortMode; label: string }> = [ + { value: "title-asc", label: "Title (A–Z)" }, + { value: "title-desc", label: "Title (Z–A)" }, + { value: "artist-asc", label: "Artist (A–Z)" }, + { value: "artist-desc", label: "Artist (Z–A)" }, + { value: "album-asc", label: "Album (A–Z)" }, + { value: "album-desc", label: "Album (Z–A)" }, + { value: "plays-desc", label: "Most played" }, + { value: "duration-desc", label: "Longest first" }, + { value: "duration-asc", label: "Shortest first" }, +]; + +function readStoredSort(key: string, options: Array<{ value: T }>, fallback: T): T { + const saved = localStorage.getItem(key); + return options.some((option) => option.value === saved) ? saved as T : fallback; +} function readDemoFavorites(): Set { const fallback = new Set(demoTracks.filter((track) => track.favorite).map((track) => track.id)); @@ -86,6 +125,10 @@ function IconButton({ label, children, onClick, active = false, pressed, disable return ; } +function SortControl({ label, value, options, onChange }: { label: string; value: T; options: Array<{ value: T; label: string }>; onChange: (value: T) => void }) { + return ; +} + const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [ { view: "home", label: "Listen now", icon: }, { view: "albums", label: "Albums", icon: }, @@ -107,6 +150,8 @@ export default function App() { const [queueAnnouncement, setQueueAnnouncement] = useState({ key: 0, message: "" }); const queueFeedbackTimers = useRef(new Map()); const [query, setQuery] = useState(""); + const [albumSort, setAlbumSort] = useState(() => readStoredSort(albumSortKey, albumSortOptions, "title-asc")); + const [trackSort, setTrackSort] = useState(() => readStoredSort(trackSortKey, trackSortOptions, "title-asc")); const [libraryAlbums, setLibraryAlbums] = useState(demoAlbums); const [libraryTracks, setLibraryTracks] = useState(demoTracks); const [spotlightTrackId, setSpotlightTrackId] = useState(() => pickRandomTrack(demoTracks)?.id ?? ""); @@ -157,12 +202,16 @@ export default function App() { ? "Repeat all is on; turn repeat one on" : "Repeat one is on; turn repeat off"; + const sortedSongs = useMemo(() => sortTracks(libraryTracks, trackSort), [libraryTracks, trackSort]); + const visibleTracks = useMemo(() => { const needle = query.trim().toLowerCase(); - let result = view === "favorites" ? libraryTracks.filter((track) => favorites.has(track.id)) : libraryTracks; + let result = view === "favorites" + ? libraryTracks.filter((track) => favorites.has(track.id)) + : view === "songs" ? sortedSongs : libraryTracks; if (needle) result = result.filter((track) => `${track.title} ${track.artist} ${track.album}`.toLowerCase().includes(needle)); return result; - }, [favorites, libraryTracks, query, view]); + }, [favorites, libraryTracks, query, sortedSongs, view]); const heavyRotationTracks = useMemo(() => selectHeavyRotation(libraryTracks), [libraryTracks]); const hasHeavyRotation = heavyRotationTracks.length > 0; @@ -175,11 +224,13 @@ export default function App() { }, [heavyRotationTracks, query, view, visibleTracks]); const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]); + const sortedAlbums = useMemo(() => sortAlbums(libraryAlbums, albumSort), [albumSort, libraryAlbums]); const visibleAlbums = useMemo(() => { const needle = query.trim().toLowerCase(); - return needle ? recentlyAddedAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : recentlyAddedAlbums; - }, [query, recentlyAddedAlbums]); + const albums = view === "albums" ? sortedAlbums : recentlyAddedAlbums; + return needle ? albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : albums; + }, [query, recentlyAddedAlbums, sortedAlbums, view]); const tracksByAlbum = useMemo(() => { const grouped = new Map(); @@ -511,6 +562,16 @@ export default function App() { if (next !== "favorites") setQuery(""); }; + const changeAlbumSort = (next: AlbumSortMode) => { + setAlbumSort(next); + localStorage.setItem(albumSortKey, next); + }; + + const changeTrackSort = (next: TrackSortMode) => { + setTrackSort(next); + localStorage.setItem(trackSortKey, next); + }; + if (isBootstrapping) { return (
@@ -606,12 +667,12 @@ export default function App() { {visibleArtists.length ?
{visibleArtists.map((artist) =>
)}
:

No artists found

Try another artist name.

} : <> {view !== "songs" && view !== "favorites" &&
-
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Newest in your library"}

{query || view === "albums" ? "Albums" : "Recently added"}

+
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Newest in your library"}

{query || view === "albums" ? "Albums" : "Recently added"}

{view === "albums" ?
{visibleAlbums.length} albums
: }
{visibleAlbums.length ?
{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const albumTracks = tracksByAlbum.get(album.id) ?? []; return

{album.title}

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

; })}
:

No albums found

Try a title, artist, or track name.

}
} {view !== "albums" &&
-
{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : query ? "Matching your search" : "Most played by listen count"}

{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}

{view === "home" && !query ? hasHeavyRotation ? "By play count" : "Still learning" : `${visibleTracks.length} tracks`}
+
{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : query ? "Matching your search" : "Most played by listen count"}

{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}

{view === "home" && !query ? hasHeavyRotation ? "By play count" : "Still learning" : `${visibleTracks.length} tracks`}{view === "songs" && }
{view === "home" && !query && !hasHeavyRotation ?

Heavy rotation is still taking shape

Finish a few songs more than once and your most-played tracks will appear here.

: view === "favorites" && visibleTracks.length === 0 ?

No favorite tracks yet

Use the heart beside a song to keep it here.

:
{displayedTracks.map((track, index) => { const justQueued = queuedTrackIds.has(track.id); const favoritePending = favoritePendingIds.has(track.id); return
{track.album} void toggleFavorite(track)}>{formatTime(track.duration)} addTrackToQueue(track)}>{justQueued ? : }
; })}
} {queueAnnouncement.message}
} diff --git a/src/lib/libraryOrdering.test.ts b/src/lib/libraryOrdering.test.ts index 105e8ad..fb4a35b 100644 --- a/src/lib/libraryOrdering.test.ts +++ b/src/lib/libraryOrdering.test.ts @@ -6,6 +6,8 @@ import { orderTracksByPlayCount, pickRandomTrack, selectHeavyRotation, + sortAlbums, + sortTracks, } from "./libraryOrdering"; const album = (id: string, addedAt?: string): Album => ({ @@ -29,6 +31,33 @@ const track = (id: string, playCount?: number): Track => ({ }); describe("library ordering", () => { + it("sorts albums alphabetically by default and supports other library orders", () => { + const albums = [ + { ...album("signal"), title: "Signal Fires", artist: "Orchard Bloom", year: 2026 }, + { ...album("quiet"), title: "A Quiet Current", artist: "Mara Venn", year: 2024 }, + { ...album("after"), title: "Afterimage", artist: "Hollow Cities", year: 2025 }, + ]; + + expect(sortAlbums(albums, "title-asc").map(({ id }) => id)).toEqual(["quiet", "after", "signal"]); + expect(sortAlbums(albums, "title-desc").map(({ id }) => id)).toEqual(["signal", "after", "quiet"]); + expect(sortAlbums(albums, "artist-asc").map(({ id }) => id)).toEqual(["after", "quiet", "signal"]); + expect(sortAlbums(albums, "year-desc").map(({ id }) => id)).toEqual(["signal", "after", "quiet"]); + }); + + it("sorts songs alphabetically and supports artist, album, play, and duration orders", () => { + const tracks = [ + { ...track("violet", 2), title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", duration: 180 }, + { ...track("antenna", 12), title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", duration: 300 }, + { ...track("understory", 4), title: "Understory", artist: "Mara Venn", album: "A Quiet Current", duration: 240 }, + ]; + + expect(sortTracks(tracks, "title-asc").map(({ id }) => id)).toEqual(["antenna", "understory", "violet"]); + expect(sortTracks(tracks, "artist-asc").map(({ id }) => id)).toEqual(["understory", "violet", "antenna"]); + expect(sortTracks(tracks, "album-asc").map(({ id }) => id)).toEqual(["understory", "antenna", "violet"]); + expect(sortTracks(tracks, "plays-desc").map(({ id }) => id)).toEqual(["antenna", "understory", "violet"]); + expect(sortTracks(tracks, "duration-asc").map(({ id }) => id)).toEqual(["violet", "understory", "antenna"]); + }); + it("puts the most recently added albums first and keeps missing dates stable", () => { const albums = [ album("older", "2026-01-02T12:00:00Z"), diff --git a/src/lib/libraryOrdering.ts b/src/lib/libraryOrdering.ts index 06eba8e..4e4a417 100644 --- a/src/lib/libraryOrdering.ts +++ b/src/lib/libraryOrdering.ts @@ -2,6 +2,61 @@ import type { Album, Track } from "../types"; export const minimumHeavyRotationPlays = 2; export const minimumHeavyRotationTracks = 3; +export type AlbumSortMode = "title-asc" | "title-desc" | "artist-asc" | "artist-desc" | "added-desc" | "added-asc" | "year-desc" | "year-asc"; +export type TrackSortMode = "title-asc" | "title-desc" | "artist-asc" | "artist-desc" | "album-asc" | "album-desc" | "plays-desc" | "duration-desc" | "duration-asc"; + +const libraryCollator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true }); + +function compareText(left: string, right: string): number { + return libraryCollator.compare(left, right); +} + +function stableSort(items: T[], compare: (left: T, right: T) => number): T[] { + return items + .map((item, index) => ({ item, index })) + .sort((left, right) => compare(left.item, right.item) || left.index - right.index) + .map(({ item }) => item); +} + +export function sortAlbums(albums: Album[], mode: AlbumSortMode): Album[] { + return stableSort(albums, (left, right) => { + if (mode === "title-desc") return compareText(right.title, left.title) || compareText(right.artist, left.artist); + if (mode === "artist-asc") return compareText(left.artist, right.artist) || compareText(left.title, right.title); + if (mode === "artist-desc") return compareText(right.artist, left.artist) || compareText(right.title, left.title); + if (mode === "added-desc" || mode === "added-asc") { + const leftDate = left.addedAt ? Date.parse(left.addedAt) : Number.NaN; + const rightDate = right.addedAt ? Date.parse(right.addedAt) : Number.NaN; + if (!Number.isFinite(leftDate)) return Number.isFinite(rightDate) ? 1 : compareText(left.title, right.title); + if (!Number.isFinite(rightDate)) return -1; + return mode === "added-desc" ? rightDate - leftDate : leftDate - rightDate; + } + if (mode === "year-desc" || mode === "year-asc") { + if (!left.year) return right.year ? 1 : compareText(left.title, right.title); + if (!right.year) return -1; + return mode === "year-desc" ? right.year - left.year : left.year - right.year; + } + return compareText(left.title, right.title) || compareText(left.artist, right.artist); + }); +} + +export function sortTracks(tracks: Track[], mode: TrackSortMode): Track[] { + return stableSort(tracks, (left, right) => { + if (mode === "title-desc") return compareText(right.title, left.title) || compareText(right.artist, left.artist); + if (mode === "artist-asc") return compareText(left.artist, right.artist) || compareText(left.title, right.title); + if (mode === "artist-desc") return compareText(right.artist, left.artist) || compareText(right.title, left.title); + if (mode === "album-asc" || mode === "album-desc") { + const albumOrder = mode === "album-asc" ? compareText(left.album, right.album) : compareText(right.album, left.album); + return albumOrder + || (left.discNumber ?? 1) - (right.discNumber ?? 1) + || (left.trackNumber ?? Number.MAX_SAFE_INTEGER) - (right.trackNumber ?? Number.MAX_SAFE_INTEGER) + || compareText(left.title, right.title); + } + if (mode === "plays-desc") return (right.playCount ?? 0) - (left.playCount ?? 0) || compareText(left.title, right.title); + if (mode === "duration-desc") return right.duration - left.duration || compareText(left.title, right.title); + if (mode === "duration-asc") return left.duration - right.duration || compareText(left.title, right.title); + return compareText(left.title, right.title) || compareText(left.artist, right.artist); + }); +} export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] { return albums diff --git a/src/styles.css b/src/styles.css index f86727b..0708c55 100644 --- a/src/styles.css +++ b/src/styles.css @@ -108,6 +108,13 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut .section-heading > button { display: flex; align-items: center; gap: 3px; padding: 5px 0; border: 0; color: var(--muted); background: transparent; font-size: 0.73rem; } .section-heading > button:hover { color: var(--ink); } .section-heading > span { color: var(--faint); font-size: 0.72rem; } +.section-heading-actions { display: flex; align-items: center; gap: 12px; } +.section-heading-actions > span { color: var(--faint); font-size: 0.72rem; white-space: nowrap; } +.sort-control { display: inline-flex; min-height: 34px; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: var(--surface); transition: border-color 150ms ease, color 150ms ease, background 150ms ease; } +.sort-control:hover { border-color: oklch(0.44 0.02 275); color: var(--ink); background: var(--surface-raised); } +.sort-control:focus-within { border-color: var(--focus); box-shadow: 0 0 0 3px oklch(0.78 0.12 255 / 0.1); } +.sort-control select { max-width: 145px; padding: 0; border: 0; outline: 0; color: inherit; background: transparent; font-size: 0.71rem; font-weight: 600; cursor: pointer; } +.sort-control option { color: var(--ink); background: var(--surface); } .album-grid { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: clamp(14px, 2vw, 25px); } .album-tile { position: relative; min-width: 0; } .album-tile > div:not(.cover) { min-width: 0; padding-top: 11px; } @@ -324,6 +331,8 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib .album-tile:nth-child(n + 5) { display: block; } .section-heading { align-items: flex-end; } .section-heading h2 { font-size: 1.25rem; } + .section-heading-actions { align-items: flex-end; flex-direction: column-reverse; gap: 5px; } + .sort-control select { max-width: 126px; } .track-row { grid-template-columns: minmax(0, 1fr) 32px 32px; } .track-time, .track-row > .icon-button:last-child { display: none; } .track-main { grid-template-columns: 25px 38px minmax(0, 1fr); gap: 8px; }