Default Albums and Songs to alphabetical order, add persistent library sort menus, and keep playback aligned with the visible song order.

Hop-State: A_06FNQ90C7TQXZ6APYR62T0G
Hop-Proposal: R_06FNQ8VERR6PDDYVM77RRZ0
Hop-Task: T_06FNQ7J1TEJ4F97CQBZP78R
Hop-Attempt: AT_06FNQ7J1TDTBP7HVX6K1FC0
This commit is contained in:
2026-07-13 06:21:45 -07:00
committed by Hop
parent c858762a4a
commit e15c692cd7
5 changed files with 194 additions and 8 deletions
+33 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @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"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const managedServer = vi.hoisted(() => ({ const managedServer = vi.hoisted(() => ({
@@ -39,6 +39,13 @@ function deferred<T>() {
return { promise, resolve }; 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(<App />);
await screen.findByText("Let it find you again.");
}
describe("App bootstrap", () => { describe("App bootstrap", () => {
beforeAll(() => vi.stubGlobal("Audio", AudioMock)); beforeAll(() => vi.stubGlobal("Audio", AudioMock));
afterAll(() => vi.unstubAllGlobals()); afterAll(() => vi.unstubAllGlobals());
@@ -83,4 +90,29 @@ describe("App bootstrap", () => {
expect(await screen.findByText("Let it find you again.")).not.toBeNull(); expect(await screen.findByText("Let it find you again.")).not.toBeNull();
}); });
it("defaults Albums and Songs to AZ 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");
});
}); });
+68 -7
View File
@@ -2,6 +2,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { import {
Album as AlbumIcon, Album as AlbumIcon,
AlertCircle, AlertCircle,
ArrowUpDown,
AudioLines, AudioLines,
Check, Check,
ChevronRight, ChevronRight,
@@ -34,7 +35,17 @@ import {
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 { 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 { fetchAllPages, pollForLibrary } from "./lib/librarySync";
import { import {
chooseMusicFolder, chooseMusicFolder,
@@ -52,6 +63,34 @@ import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } fr
const tones: CoverTone[] = ["ember", "moss", "dusk", "clay", "indigo", "sand"]; const tones: CoverTone[] = ["ember", "moss", "dusk", "clay", "indigo", "sand"];
const queuePreviewLimit = 100; const queuePreviewLimit = 100;
const demoFavoritesKey = "resonant.demoFavorites"; const demoFavoritesKey = "resonant.demoFavorites";
const albumSortKey = "resonant.albumSort";
const trackSortKey = "resonant.trackSort";
const albumSortOptions: Array<{ value: AlbumSortMode; label: string }> = [
{ value: "title-asc", label: "Title (AZ)" },
{ value: "title-desc", label: "Title (ZA)" },
{ value: "artist-asc", label: "Artist (AZ)" },
{ value: "artist-desc", label: "Artist (ZA)" },
{ 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 (AZ)" },
{ value: "title-desc", label: "Title (ZA)" },
{ value: "artist-asc", label: "Artist (AZ)" },
{ value: "artist-desc", label: "Artist (ZA)" },
{ value: "album-asc", label: "Album (AZ)" },
{ value: "album-desc", label: "Album (ZA)" },
{ value: "plays-desc", label: "Most played" },
{ value: "duration-desc", label: "Longest first" },
{ value: "duration-asc", label: "Shortest first" },
];
function readStoredSort<T extends string>(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<string> { function readDemoFavorites(): Set<string> {
const fallback = new Set(demoTracks.filter((track) => track.favorite).map((track) => track.id)); 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 <button type="button" className={`icon-button ${active ? "is-active" : ""} ${className}`} aria-label={label} aria-pressed={pressed ?? (active || undefined)} disabled={disabled} onClick={onClick}>{children}</button>; return <button type="button" className={`icon-button ${active ? "is-active" : ""} ${className}`} aria-label={label} aria-pressed={pressed ?? (active || undefined)} disabled={disabled} onClick={onClick}>{children}</button>;
} }
function SortControl<T extends string>({ label, value, options, onChange }: { label: string; value: T; options: Array<{ value: T; label: string }>; onChange: (value: T) => void }) {
return <label className="sort-control"><ArrowUpDown size={14} aria-hidden="true" /><span className="sr-only">{label}</span><select aria-label={label} value={value} onChange={(event) => onChange(event.target.value as T)}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
}
const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [ const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
{ view: "home", label: "Listen now", icon: <Home size={18} /> }, { view: "home", label: "Listen now", icon: <Home size={18} /> },
{ view: "albums", label: "Albums", icon: <AlbumIcon size={18} /> }, { view: "albums", label: "Albums", icon: <AlbumIcon size={18} /> },
@@ -107,6 +150,8 @@ export default function App() {
const [queueAnnouncement, setQueueAnnouncement] = useState({ key: 0, message: "" }); const [queueAnnouncement, setQueueAnnouncement] = useState({ key: 0, message: "" });
const queueFeedbackTimers = useRef(new Map<string, number>()); const queueFeedbackTimers = useRef(new Map<string, number>());
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [albumSort, setAlbumSort] = useState<AlbumSortMode>(() => readStoredSort(albumSortKey, albumSortOptions, "title-asc"));
const [trackSort, setTrackSort] = useState<TrackSortMode>(() => readStoredSort(trackSortKey, trackSortOptions, "title-asc"));
const [libraryAlbums, setLibraryAlbums] = useState<Album[]>(demoAlbums); const [libraryAlbums, setLibraryAlbums] = useState<Album[]>(demoAlbums);
const [libraryTracks, setLibraryTracks] = useState<Track[]>(demoTracks); const [libraryTracks, setLibraryTracks] = useState<Track[]>(demoTracks);
const [spotlightTrackId, setSpotlightTrackId] = useState(() => pickRandomTrack(demoTracks)?.id ?? ""); const [spotlightTrackId, setSpotlightTrackId] = useState(() => pickRandomTrack(demoTracks)?.id ?? "");
@@ -157,12 +202,16 @@ export default function App() {
? "Repeat all is on; turn repeat one on" ? "Repeat all is on; turn repeat one on"
: "Repeat one is on; turn repeat off"; : "Repeat one is on; turn repeat off";
const sortedSongs = useMemo(() => sortTracks(libraryTracks, trackSort), [libraryTracks, trackSort]);
const visibleTracks = useMemo(() => { const visibleTracks = useMemo(() => {
const needle = query.trim().toLowerCase(); 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)); if (needle) result = result.filter((track) => `${track.title} ${track.artist} ${track.album}`.toLowerCase().includes(needle));
return result; return result;
}, [favorites, libraryTracks, query, view]); }, [favorites, libraryTracks, query, sortedSongs, view]);
const heavyRotationTracks = useMemo(() => selectHeavyRotation(libraryTracks), [libraryTracks]); const heavyRotationTracks = useMemo(() => selectHeavyRotation(libraryTracks), [libraryTracks]);
const hasHeavyRotation = heavyRotationTracks.length > 0; const hasHeavyRotation = heavyRotationTracks.length > 0;
@@ -175,11 +224,13 @@ export default function App() {
}, [heavyRotationTracks, query, view, visibleTracks]); }, [heavyRotationTracks, query, view, visibleTracks]);
const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]); const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]);
const sortedAlbums = useMemo(() => sortAlbums(libraryAlbums, albumSort), [albumSort, libraryAlbums]);
const visibleAlbums = useMemo(() => { const visibleAlbums = useMemo(() => {
const needle = query.trim().toLowerCase(); const needle = query.trim().toLowerCase();
return needle ? recentlyAddedAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : recentlyAddedAlbums; const albums = view === "albums" ? sortedAlbums : recentlyAddedAlbums;
}, [query, recentlyAddedAlbums]); return needle ? albums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : albums;
}, [query, recentlyAddedAlbums, sortedAlbums, view]);
const tracksByAlbum = useMemo(() => { const tracksByAlbum = useMemo(() => {
const grouped = new Map<string, Track[]>(); const grouped = new Map<string, Track[]>();
@@ -511,6 +562,16 @@ export default function App() {
if (next !== "favorites") setQuery(""); 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) { if (isBootstrapping) {
return ( return (
<div className="app-bootstrap" role="status" aria-live="polite"> <div className="app-bootstrap" role="status" aria-live="polite">
@@ -606,12 +667,12 @@ export default function App() {
{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>} {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> : <> </section> : <>
{view !== "songs" && view !== "favorites" && <section className="content-section" aria-labelledby="albums-heading"> {view !== "songs" && view !== "favorites" && <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" : "Newest in your library"}</span><h2 id="albums-heading">{query || view === "albums" ? "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" : "Newest in your library"}</span><h2 id="albums-heading">{query || view === "albums" ? "Albums" : "Recently added"}</h2></div>{view === "albums" ? <div className="section-heading-actions"><span>{visibleAlbums.length} albums</span><SortControl label="Sort albums" value={albumSort} options={albumSortOptions} onChange={changeAlbumSort} /></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 albumTracks = tracksByAlbum.get(album.id) ?? []; return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => player.playCollection(albumTracks)}><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 albumTracks = tracksByAlbum.get(album.id) ?? []; return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => player.playCollection(albumTracks)}><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>}
</section>} </section>}
{view !== "albums" && <section className="content-section track-section" aria-labelledby="tracks-heading"> {view !== "albums" && <section className="content-section track-section" aria-labelledby="tracks-heading">
<div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : query ? "Matching your search" : "Most played by listen count"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}</h2></div><span>{view === "home" && !query ? hasHeavyRotation ? "By play count" : "Still learning" : `${visibleTracks.length} tracks`}</span></div> <div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : query ? "Matching your search" : "Most played by listen count"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}</h2></div><div className="section-heading-actions"><span>{view === "home" && !query ? hasHeavyRotation ? "By play count" : "Still learning" : `${visibleTracks.length} tracks`}</span>{view === "songs" && <SortControl label="Sort songs" value={trackSort} options={trackSortOptions} onChange={changeTrackSort} />}</div></div>
{view === "home" && !query && !hasHeavyRotation ? <div className="empty-state"><AudioLines size={26} /><h3>Heavy rotation is still taking shape</h3><p>Finish a few songs more than once and your most-played tracks will appear here.</p></div> : view === "favorites" && visibleTracks.length === 0 ? <div className="empty-state"><Heart size={26} /><h3>No favorite tracks yet</h3><p>Use the heart beside a song to keep it here.</p></div> : <div className="track-list" role="list">{displayedTracks.map((track, index) => { const justQueued = queuedTrackIds.has(track.id); const favoritePending = favoritePendingIds.has(track.id); return <div className={`track-row ${player.current.id === track.id ? "is-current" : ""}`} role="listitem" key={track.id}><button className="track-main" type="button" onClick={() => player.playFromContext(track, displayedTracks)}><span className="track-number">{player.current.id === track.id && player.isPlaying ? <AudioLines size={15} /> : String(index + 1).padStart(2, "0")}</span><Cover album={track} size="small" /><span><strong>{track.title}</strong><small>{track.artist}</small></span></button><span className="track-album">{track.album}</span><IconButton label={favoritePending ? `Saving favorite for ${track.title}` : favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} disabled={favoritePending} onClick={() => void toggleFavorite(track)}><Heart size={16} fill={favorites.has(track.id) ? "currentColor" : "none"} /></IconButton><span className="track-time">{formatTime(track.duration)}</span><IconButton label={justQueued ? `Add ${track.title} to queue again` : `Add ${track.title} to queue`} className={`queue-add-button ${justQueued ? "is-confirmed" : ""}`} onClick={() => addTrackToQueue(track)}>{justQueued ? <Check size={17} strokeWidth={2.5} /> : <Plus size={17} />}</IconButton></div>; })}</div>} {view === "home" && !query && !hasHeavyRotation ? <div className="empty-state"><AudioLines size={26} /><h3>Heavy rotation is still taking shape</h3><p>Finish a few songs more than once and your most-played tracks will appear here.</p></div> : view === "favorites" && visibleTracks.length === 0 ? <div className="empty-state"><Heart size={26} /><h3>No favorite tracks yet</h3><p>Use the heart beside a song to keep it here.</p></div> : <div className="track-list" role="list">{displayedTracks.map((track, index) => { const justQueued = queuedTrackIds.has(track.id); const favoritePending = favoritePendingIds.has(track.id); return <div className={`track-row ${player.current.id === track.id ? "is-current" : ""}`} role="listitem" key={track.id}><button className="track-main" type="button" onClick={() => player.playFromContext(track, displayedTracks)}><span className="track-number">{player.current.id === track.id && player.isPlaying ? <AudioLines size={15} /> : String(index + 1).padStart(2, "0")}</span><Cover album={track} size="small" /><span><strong>{track.title}</strong><small>{track.artist}</small></span></button><span className="track-album">{track.album}</span><IconButton label={favoritePending ? `Saving favorite for ${track.title}` : favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} disabled={favoritePending} onClick={() => void toggleFavorite(track)}><Heart size={16} fill={favorites.has(track.id) ? "currentColor" : "none"} /></IconButton><span className="track-time">{formatTime(track.duration)}</span><IconButton label={justQueued ? `Add ${track.title} to queue again` : `Add ${track.title} to queue`} className={`queue-add-button ${justQueued ? "is-confirmed" : ""}`} onClick={() => addTrackToQueue(track)}>{justQueued ? <Check size={17} strokeWidth={2.5} /> : <Plus size={17} />}</IconButton></div>; })}</div>}
<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>}
+29
View File
@@ -6,6 +6,8 @@ import {
orderTracksByPlayCount, orderTracksByPlayCount,
pickRandomTrack, pickRandomTrack,
selectHeavyRotation, selectHeavyRotation,
sortAlbums,
sortTracks,
} from "./libraryOrdering"; } from "./libraryOrdering";
const album = (id: string, addedAt?: string): Album => ({ const album = (id: string, addedAt?: string): Album => ({
@@ -29,6 +31,33 @@ const track = (id: string, playCount?: number): Track => ({
}); });
describe("library ordering", () => { 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", () => { it("puts the most recently added albums first and keeps missing dates stable", () => {
const albums = [ const albums = [
album("older", "2026-01-02T12:00:00Z"), album("older", "2026-01-02T12:00:00Z"),
+55
View File
@@ -2,6 +2,61 @@ import type { Album, Track } from "../types";
export const minimumHeavyRotationPlays = 2; export const minimumHeavyRotationPlays = 2;
export const minimumHeavyRotationTracks = 3; 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<T>(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[] { export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] {
return albums return albums
+9
View File
@@ -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 { 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 > button:hover { color: var(--ink); }
.section-heading > span { color: var(--faint); font-size: 0.72rem; } .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-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 { position: relative; min-width: 0; }
.album-tile > div:not(.cover) { min-width: 0; padding-top: 11px; } .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; } .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; }
.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-row { grid-template-columns: minmax(0, 1fr) 32px 32px; }
.track-time, .track-row > .icon-button:last-child { display: none; } .track-time, .track-row > .icon-button:last-child { display: none; }
.track-main { grid-template-columns: 25px 38px minmax(0, 1fr); gap: 8px; } .track-main { grid-template-columns: 25px 38px minmax(0, 1fr); gap: 8px; }