Gate launch until library restoration completes, make playback context-aware with opt-in shuffle and ordered album/artist queues, and show Heavy Rotation only after meaningful repeat listens.

Hop-State: A_06FNQ6AXS34K9AR7VB49SNR
Hop-Proposal: R_06FNQ6AARMBP75PB7NYVYC8
Hop-Task: T_06FNQ3V4MZHA2TBQB6RT1V0
Hop-Attempt: AT_06FNQ3V4MZRVJ31NQZ8K7DG
This commit is contained in:
2026-07-13 06:10:05 -07:00
committed by Hop
parent f3b1d85936
commit c858762a4a
9 changed files with 320 additions and 52 deletions
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const managedServer = vi.hoisted(() => ({
chooseMusicFolder: vi.fn(),
getManagedServerStatus: vi.fn(),
getSavedCredentials: vi.fn(),
isDesktopRuntime: vi.fn(),
saveRemotePassword: vi.fn(),
startManagedServer: vi.fn(),
stopManagedServer: vi.fn(),
}));
vi.mock("./lib/managedServer", () => managedServer);
import App from "./App";
class AudioMock extends EventTarget {
currentTime = 0;
duration = 180;
paused = true;
preload = "";
src = "";
volume = 1;
load() {}
pause() { this.paused = true; }
play() {
this.paused = false;
return Promise.resolve();
}
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
describe("App bootstrap", () => {
beforeAll(() => vi.stubGlobal("Audio", AudioMock));
afterAll(() => vi.unstubAllGlobals());
afterEach(cleanup);
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
managedServer.isDesktopRuntime.mockReturnValue(false);
});
it("keeps a saved library behind the launch gate while credentials load", () => {
localStorage.setItem("resonant.sourceMode", "remote");
localStorage.setItem("resonant.serverUrl", "https://music.example.test");
localStorage.setItem("resonant.username", "listener");
managedServer.getManagedServerStatus.mockReturnValue(new Promise(() => undefined));
managedServer.getSavedCredentials.mockReturnValue(new Promise(() => undefined));
render(<App />);
expect(screen.getByText("Opening your library")).not.toBeNull();
expect(screen.queryByText("Let it find you again.")).toBeNull();
expect(screen.queryByText("Afterimage")).toBeNull();
});
it("reveals the demo only after bootstrap confirms there is no saved library", async () => {
const status = deferred<{ running: boolean; url: string; lanUrls: string[]; version: string }>();
const credentials = deferred<Record<string, never>>();
managedServer.getManagedServerStatus.mockReturnValue(status.promise);
managedServer.getSavedCredentials.mockReturnValue(credentials.promise);
render(<App />);
expect(screen.getByText("Opening your library")).not.toBeNull();
expect(screen.queryByText("Let it find you again.")).toBeNull();
await act(async () => {
status.resolve({ running: false, url: "http://127.0.0.1:4533", lanUrls: [], version: "0.61.2" });
credentials.resolve({});
await Promise.all([status.promise, credentials.promise]);
});
expect(await screen.findByText("Let it find you again.")).not.toBeNull();
});
});
+66 -13
View File
@@ -34,7 +34,7 @@ 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, orderTracksByPlayCount, pickRandomTrack } from "./lib/libraryOrdering"; import { orderAlbumsByRecentlyAdded, orderAlbumTracks, orderTracksByPlayCount, pickRandomTrack, selectHeavyRotation } from "./lib/libraryOrdering";
import { fetchAllPages, pollForLibrary } from "./lib/librarySync"; import { fetchAllPages, pollForLibrary } from "./lib/librarySync";
import { import {
chooseMusicFolder, chooseMusicFolder,
@@ -95,7 +95,7 @@ const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
]; ];
interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: string; created?: string } interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: string; created?: string }
interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string; playCount?: number } interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string; playCount?: number; discNumber?: number; track?: number }
interface LoadedLibrary { albums: Album[]; tracks: Track[] } interface LoadedLibrary { albums: Album[]; tracks: Track[] }
type LibraryPhase = "ready" | "scanning" | "empty"; type LibraryPhase = "ready" | "scanning" | "empty";
@@ -117,6 +117,7 @@ export default function App() {
const [favoriteAnnouncement, setFavoriteAnnouncement] = useState({ key: 0, message: "" }); const [favoriteAnnouncement, setFavoriteAnnouncement] = useState({ key: 0, message: "" });
const [favoriteError, setFavoriteError] = useState<string | null>(null); const [favoriteError, setFavoriteError] = useState<string | null>(null);
const favoriteErrorTimer = useRef<number | null>(null); const favoriteErrorTimer = useRef<number | null>(null);
const [isBootstrapping, setIsBootstrapping] = useState(true);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("demo"); const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("demo");
const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library"); const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library");
const [libraryPhase, setLibraryPhase] = useState<LibraryPhase>("ready"); const [libraryPhase, setLibraryPhase] = useState<LibraryPhase>("ready");
@@ -163,10 +164,15 @@ export default function App() {
return result; return result;
}, [favorites, libraryTracks, query, view]); }, [favorites, libraryTracks, query, view]);
const heavyRotationTracks = useMemo(() => selectHeavyRotation(libraryTracks), [libraryTracks]);
const hasHeavyRotation = heavyRotationTracks.length > 0;
const displayedTracks = useMemo(() => { const displayedTracks = useMemo(() => {
const tracks = view === "home" ? orderTracksByPlayCount(visibleTracks) : visibleTracks; const tracks = view === "home"
? query ? orderTracksByPlayCount(visibleTracks) : heavyRotationTracks
: visibleTracks;
return tracks.slice(0, view === "songs" ? tracks.length : view === "home" && !query ? 6 : 30); return tracks.slice(0, view === "songs" ? tracks.length : view === "home" && !query ? 6 : 30);
}, [query, view, visibleTracks]); }, [heavyRotationTracks, query, view, visibleTracks]);
const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]); const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]);
@@ -175,6 +181,16 @@ export default function App() {
return needle ? recentlyAddedAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : recentlyAddedAlbums; return needle ? recentlyAddedAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : recentlyAddedAlbums;
}, [query, recentlyAddedAlbums]); }, [query, recentlyAddedAlbums]);
const tracksByAlbum = useMemo(() => {
const grouped = new Map<string, Track[]>();
libraryTracks.forEach((track) => {
const albumTracks = grouped.get(track.albumId);
if (albumTracks) albumTracks.push(track); else grouped.set(track.albumId, [track]);
});
grouped.forEach((tracks, albumId) => grouped.set(albumId, orderAlbumTracks(tracks)));
return grouped;
}, [libraryTracks]);
const artists = useMemo(() => buildArtistSummaries(libraryTracks), [libraryTracks]); const artists = useMemo(() => buildArtistSummaries(libraryTracks), [libraryTracks]);
const visibleArtists = useMemo(() => { const visibleArtists = useMemo(() => {
@@ -299,6 +315,8 @@ export default function App() {
streamUrl: streamUrl(config, song.id), streamUrl: streamUrl(config, song.id),
favorite: Boolean(song.starred), favorite: Boolean(song.starred),
playCount: song.playCount ?? 0, playCount: song.playCount ?? 0,
discNumber: song.discNumber,
trackNumber: song.track,
})); }));
return { albums, tracks }; return { albums, tracks };
}; };
@@ -396,6 +414,11 @@ export default function App() {
setConnectionMessage("Your local library is ready"); setConnectionMessage("Your local library is ready");
} catch (error) { } catch (error) {
setManagedStatus(await getManagedServerStatus().catch(() => null)); setManagedStatus(await getManagedServerStatus().catch(() => null));
setLibraryAlbums([]);
setLibraryTracks([]);
setSpotlightTrackId("");
commitFavorites(new Set());
player.clearTracks();
setLibraryPhase("empty"); setLibraryPhase("empty");
setConnectionStatus("error"); setConnectionStatus("error");
setConnectionMessage(error instanceof Error ? error.message : "Your local library could not start."); setConnectionMessage(error instanceof Error ? error.message : "Your local library could not start.");
@@ -428,6 +451,12 @@ export default function App() {
useEffect(() => { useEffect(() => {
let active = true; let active = true;
const restoreLibrary = async () => { const restoreLibrary = async () => {
const savedMode = localStorage.getItem("resonant.sourceMode");
let restoringSavedLibrary = savedMode === "local"
? Boolean(localStorage.getItem("resonant.musicFolder"))
: savedMode === "remote"
? Boolean(server.url && server.username)
: false;
try { try {
const [status, credentials] = await Promise.all([ const [status, credentials] = await Promise.all([
getManagedServerStatus(), getManagedServerStatus(),
@@ -442,16 +471,29 @@ export default function App() {
setLocalSetup({ musicFolder, password: localPassword }); setLocalSetup({ musicFolder, password: localPassword });
setServer((current) => ({ ...current, password: remotePassword })); setServer((current) => ({ ...current, password: remotePassword }));
const savedMode = localStorage.getItem("resonant.sourceMode"); if (savedMode === "local" && musicFolder) {
if (savedMode === "local" && musicFolder && localPassword) { restoringSavedLibrary = true;
if (!localPassword) throw new Error("Your saved library password is unavailable. Open Library source to reconnect it.");
await runLocalLibrary(musicFolder, localPassword); await runLocalLibrary(musicFolder, localPassword);
} else if (savedMode === "remote" && server.url && server.username && remotePassword) { } else if (savedMode === "remote" && server.url && server.username) {
restoringSavedLibrary = true;
if (!remotePassword) throw new Error("Your saved server password is unavailable. Open Library source to reconnect it.");
await loadServerLibrary({ ...server, password: remotePassword }, `Connected as ${server.username}`); await loadServerLibrary({ ...server, password: remotePassword }, `Connected as ${server.username}`);
} }
} catch (error) { } catch (error) {
if (!active) return; if (!active) return;
if (restoringSavedLibrary) {
setLibraryAlbums([]);
setLibraryTracks([]);
setSpotlightTrackId("");
commitFavorites(new Set());
player.clearTracks();
setLibraryPhase("empty");
}
setConnectionStatus("error"); setConnectionStatus("error");
setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored."); setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored.");
} finally {
if (active) setIsBootstrapping(false);
} }
}; };
void restoreLibrary(); void restoreLibrary();
@@ -469,6 +511,17 @@ export default function App() {
if (next !== "favorites") setQuery(""); if (next !== "favorites") setQuery("");
}; };
if (isBootstrapping) {
return (
<div className="app-bootstrap" role="status" aria-live="polite">
<div className="brand"><div className="brand-mark"><AudioLines size={19} /></div><span>Resonant</span></div>
<div className="library-status-icon is-scanning"><Disc3 size={27} /></div>
<strong>Opening your library</strong>
<span>Restoring your saved connection</span>
</div>
);
}
return ( return (
<div className="app-shell"> <div className="app-shell">
<a className="skip-link" href="#main-content">Skip to library</a> <a className="skip-link" href="#main-content">Skip to library</a>
@@ -543,7 +596,7 @@ export default function App() {
) : <> ) : <>
{hasLibraryTracks && spotlightTrack && view === "home" && !query && ( {hasLibraryTracks && spotlightTrack && view === "home" && !query && (
<section className="hero" aria-labelledby="hero-title"> <section className="hero" aria-labelledby="hero-title">
<div className="hero-copy"><span className="eyebrow">Picked from your library</span><h1 id="hero-title">Let it find you again.</h1><p><strong>{spotlightTrack.title}</strong> by {spotlightTrack.artist} surfaced from your library. Start here, then let the queue wander.</p><div className="hero-actions"><button className="button-primary button-primary--light" type="button" onClick={() => spotlightIsPlaying ? player.toggle() : player.play(spotlightTrack)}>{spotlightIsPlaying ? <Pause size={17} fill="currentColor" /> : <Play size={17} fill="currentColor" />} {spotlightIsPlaying ? "Pause" : "Play now"}</button><button className={`button-quiet ${player.isShuffled ? "is-active" : ""}`} type="button" aria-pressed={player.isShuffled} onClick={player.toggleShuffle}><Shuffle size={17} /> {player.isShuffled ? "Shuffle on" : "Shuffle queue"}</button></div></div> <div className="hero-copy"><span className="eyebrow">Picked from your library</span><h1 id="hero-title">Let it find you again.</h1><p><strong>{spotlightTrack.title}</strong> by {spotlightTrack.artist} surfaced from your library. Start here, then let the queue wander.</p><div className="hero-actions"><button className="button-primary button-primary--light" type="button" onClick={() => spotlightIsPlaying ? player.toggle() : player.playCollection([spotlightTrack, ...libraryTracks.filter((track) => track.id !== spotlightTrack.id)])}>{spotlightIsPlaying ? <Pause size={17} fill="currentColor" /> : <Play size={17} fill="currentColor" />} {spotlightIsPlaying ? "Pause" : "Play now"}</button><button className={`button-quiet ${player.isShuffled ? "is-active" : ""}`} type="button" aria-pressed={player.isShuffled} onClick={player.toggleShuffle}><Shuffle size={17} /> {player.isShuffled ? "Shuffle on" : "Shuffle queue"}</button></div></div>
<div className="hero-art" aria-hidden="true"><Cover album={spotlightTrack} size="large" /><div className="orbit orbit-one" /><div className="orbit orbit-two" /></div> <div className="hero-art" aria-hidden="true"><Cover album={spotlightTrack} size="large" /><div className="orbit orbit-one" /><div className="orbit orbit-two" /></div>
</section> </section>
)} )}
@@ -554,14 +607,14 @@ export default function App() {
</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><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 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>}
<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 ? "By play count" : `${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><span>{view === "home" && !query ? hasHeavyRotation ? "By play count" : "Still learning" : `${visibleTracks.length} tracks`}</span></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.play(track)}><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>}
</>} </>}
</>} </>}
</div> </div>
+8 -8
View File
@@ -10,14 +10,14 @@ export const albums: Album[] = [
]; ];
export const tracks: Track[] = [ export const tracks: Track[] = [
{ id: "t1", title: "Still Life in Motion", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 238, coverTone: "ember", favorite: true, playCount: 12 }, { id: "t1", title: "Still Life in Motion", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 238, coverTone: "ember", favorite: true, discNumber: 1, trackNumber: 1 },
{ id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss", playCount: 27 }, { id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss", discNumber: 1, trackNumber: 1 },
{ id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true, playCount: 18 }, { id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true, discNumber: 1, trackNumber: 1 },
{ id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay", playCount: 5 }, { id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay", discNumber: 1, trackNumber: 1 },
{ id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo", playCount: 34 }, { id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo", discNumber: 1, trackNumber: 1 },
{ id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand", playCount: 2 }, { id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand", discNumber: 1, trackNumber: 1 },
{ id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember", playCount: 21 }, { id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember", discNumber: 1, trackNumber: 2 },
{ id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss", playCount: 9 }, { id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss", discNumber: 1, trackNumber: 2 },
]; ];
export const formatTime = (seconds: number) => { export const formatTime = (seconds: number) => {
+35
View File
@@ -80,6 +80,41 @@ describe("usePlayer playback modes", () => {
expect(result.current.repeatMode).toBe("off"); expect(result.current.repeatMode).toBe("off");
}); });
it("starts an album or artist context in its supplied order and turns shuffle off", () => {
const { result } = renderHook(() => usePlayer(tracks));
act(() => result.current.toggleShuffle());
expect(result.current.isShuffled).toBe(true);
const collection = [tracks[2], tracks[0], tracks[3]];
act(() => result.current.playCollection(collection));
expect(result.current.isShuffled).toBe(false);
expect(result.current.current.id).toBe(tracks[2].id);
expect(result.current.queue.map((track) => track.id)).toEqual([tracks[0].id, tracks[3].id]);
});
it("plays a selected song followed only by the rest of its visible context", () => {
const { result } = renderHook(() => usePlayer(tracks));
act(() => result.current.playFromContext(tracks[1], tracks));
expect(result.current.current.id).toBe(tracks[1].id);
expect(result.current.queue.map((track) => track.id)).toEqual([tracks[2].id, tracks[3].id]);
expect(result.current.isShuffled).toBe(false);
});
it("restores the original context order when shuffle is turned back off", () => {
const { result } = renderHook(() => usePlayer(tracks));
const originalQueue = result.current.queue.map((track) => track.id);
act(() => result.current.toggleShuffle());
expect(result.current.queue.map((track) => track.id)).not.toEqual(originalQueue);
act(() => result.current.toggleShuffle());
expect(result.current.queue.map((track) => track.id)).toEqual(originalQueue);
});
it("keeps the complete loaded library available to skip forward", () => { it("keeps the complete loaded library available to skip forward", () => {
const { result } = renderHook(() => usePlayer(tracks)); const { result } = renderHook(() => usePlayer(tracks));
+69 -30
View File
@@ -2,12 +2,18 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback"; import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback";
import type { Track } from "../types"; import type { Track } from "../types";
function removeFirstTrack(items: Track[], id: string): Track[] {
const index = items.findIndex((track) => track.id === id);
return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)];
}
export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Track) => void) { export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Track) => void) {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const onTrackCompletedRef = useRef(onTrackCompleted); const onTrackCompletedRef = useRef(onTrackCompleted);
const tracksRef = useRef(initialTracks); const contextRef = useRef(initialTracks);
const currentRef = useRef<Track>(initialTracks[0]); const currentRef = useRef<Track>(initialTracks[0]);
const queueRef = useRef<Track[]>(initialTracks.slice(1)); const queueRef = useRef<Track[]>(initialTracks.slice(1));
const unshuffledQueueRef = useRef<Track[]>(initialTracks.slice(1));
const historyRef = useRef<Track[]>([]); const historyRef = useRef<Track[]>([]);
const shuffleRef = useRef(false); const shuffleRef = useRef(false);
const repeatRef = useRef<RepeatMode>("off"); const repeatRef = useRef<RepeatMode>("off");
@@ -32,6 +38,11 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
setQueue(items); setQueue(items);
}, []); }, []);
const commitShuffle = useCallback((enabled: boolean) => {
shuffleRef.current = enabled;
setIsShuffled(enabled);
}, []);
const restartCurrent = useCallback((resume: boolean) => { const restartCurrent = useCallback((resume: boolean) => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
@@ -53,13 +64,15 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
if (nextTrack) { if (nextTrack) {
historyRef.current = [...historyRef.current, currentTrack]; historyRef.current = [...historyRef.current, currentTrack];
unshuffledQueueRef.current = removeFirstTrack(unshuffledQueueRef.current, nextTrack.id);
commitQueue(queueRef.current.slice(1)); commitQueue(queueRef.current.slice(1));
commitCurrent(nextTrack); commitCurrent(nextTrack);
return; return;
} }
if (repeatRef.current === "all") { if (repeatRef.current === "all") {
let cycle = [...historyRef.current, currentTrack]; const orderedContext = contextRef.current.length ? contextRef.current : [...historyRef.current, currentTrack];
let cycle = [...orderedContext];
if (shuffleRef.current) { if (shuffleRef.current) {
cycle = shuffleTracks(cycle); cycle = shuffleTracks(cycle);
if (cycle.length > 1 && cycle[0].id === currentTrack.id) { if (cycle.length > 1 && cycle[0].id === currentTrack.id) {
@@ -75,6 +88,15 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
commitCurrent(cycle[0]); commitCurrent(cycle[0]);
commitQueue(cycle.slice(1)); commitQueue(cycle.slice(1));
if (shuffleRef.current) {
const originalIndex = orderedContext.findIndex((track) => track.id === cycle[0].id);
unshuffledQueueRef.current = [
...orderedContext.slice(originalIndex + 1),
...orderedContext.slice(0, originalIndex),
];
} else {
unshuffledQueueRef.current = cycle.slice(1);
}
return; return;
} }
@@ -121,28 +143,46 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
return; return;
} }
const queueIndex = queueRef.current.findIndex((queuedTrack) => queuedTrack.id === track.id);
const skippedTracks = queueIndex >= 0 ? queueRef.current.slice(0, queueIndex) : [];
const upcoming = queueIndex >= 0
? queueRef.current.slice(queueIndex + 1)
: queueRef.current.filter((queuedTrack) => queuedTrack.id !== track.id);
const consumedIds = new Set([...skippedTracks, track].map((item) => item.id));
historyRef.current = [...historyRef.current, currentRef.current]; historyRef.current = [...historyRef.current, currentRef.current];
commitQueue(queueRef.current.filter((queuedTrack) => queuedTrack.id !== track.id)); unshuffledQueueRef.current = shuffleRef.current
? unshuffledQueueRef.current.filter((queuedTrack) => !consumedIds.has(queuedTrack.id))
: upcoming;
contextRef.current = [track, ...upcoming];
commitQueue(upcoming);
commitCurrent(track); commitCurrent(track);
setIsPlaying(true); setIsPlaying(true);
}, [commitCurrent, commitQueue]); }, [commitCurrent, commitQueue]);
const playCollection = useCallback((items: Track[]) => { const playFromContext = useCallback((track: Track, items: Track[]) => {
const firstTrack = items[0]; const trackIndex = items.findIndex((item) => item.id === track.id);
if (!firstTrack) return; const context = trackIndex >= 0 ? [...items] : [track];
const upcoming = trackIndex >= 0 ? items.slice(trackIndex + 1) : [];
historyRef.current = []; historyRef.current = [];
const upcoming = shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1); contextRef.current = context;
unshuffledQueueRef.current = upcoming;
commitShuffle(false);
commitQueue(upcoming); commitQueue(upcoming);
if (firstTrack.id === currentRef.current.id) { if (track.id === currentRef.current.id) {
void audioRef.current?.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false)); void audioRef.current?.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
return; return;
} }
commitCurrent(firstTrack); commitCurrent(track);
setIsPlaying(true); setIsPlaying(true);
}, [commitCurrent, commitQueue]); }, [commitCurrent, commitQueue, commitShuffle]);
const playCollection = useCallback((items: Track[]) => {
if (items[0]) playFromContext(items[0], items);
}, [playFromContext]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
const audio = audioRef.current; const audio = audioRef.current;
@@ -177,35 +217,26 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
const previousTrack = historyRef.current.at(-1); const previousTrack = historyRef.current.at(-1);
if (!previousTrack) return; if (!previousTrack) return;
historyRef.current = historyRef.current.slice(0, -1); historyRef.current = historyRef.current.slice(0, -1);
unshuffledQueueRef.current = [currentRef.current, ...unshuffledQueueRef.current];
commitQueue([currentRef.current, ...queueRef.current]); commitQueue([currentRef.current, ...queueRef.current]);
commitCurrent(previousTrack); commitCurrent(previousTrack);
}, [commitCurrent, commitQueue]); }, [commitCurrent, commitQueue]);
const toggleShuffle = useCallback(() => { const toggleShuffle = useCallback(() => {
const nextShuffled = !shuffleRef.current; const nextShuffled = !shuffleRef.current;
shuffleRef.current = nextShuffled; commitShuffle(nextShuffled);
setIsShuffled(nextShuffled);
let upcoming = queueRef.current;
if (nextShuffled) { if (nextShuffled) {
if (upcoming.length === 0) { const shuffled = shuffleTracks(unshuffledQueueRef.current);
const playedIds = new Set([...historyRef.current, currentRef.current].map((track) => track.id)); if (shuffled.length > 1 && shuffled.every((track, index) => track.id === unshuffledQueueRef.current[index].id)) {
upcoming = tracksRef.current.filter((track) => !playedIds.has(track.id));
}
const shuffled = shuffleTracks(upcoming);
if (shuffled.length > 1 && shuffled.every((track, index) => track.id === upcoming[index].id)) {
shuffled.push(shuffled.shift()!); shuffled.push(shuffled.shift()!);
} }
commitQueue(shuffled); commitQueue(shuffled);
return; return;
} }
const libraryOrder = new Map(tracksRef.current.map((track, index) => [track.id, index])); commitQueue(unshuffledQueueRef.current);
commitQueue(upcoming }, [commitQueue, commitShuffle]);
.map((track, index) => ({ track, index }))
.sort((a, b) => (libraryOrder.get(a.track.id) ?? Number.MAX_SAFE_INTEGER) - (libraryOrder.get(b.track.id) ?? Number.MAX_SAFE_INTEGER) || a.index - b.index)
.map(({ track }) => track));
}, [commitQueue]);
const cycleRepeat = useCallback(() => { const cycleRepeat = useCallback(() => {
const nextMode = nextRepeatMode(repeatRef.current); const nextMode = nextRepeatMode(repeatRef.current);
@@ -214,34 +245,41 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
}, []); }, []);
const addToQueue = useCallback((track: Track) => { const addToQueue = useCallback((track: Track) => {
contextRef.current = [...contextRef.current, track];
unshuffledQueueRef.current = [...unshuffledQueueRef.current, track];
commitQueue([...queueRef.current, track]); commitQueue([...queueRef.current, track]);
}, [commitQueue]); }, [commitQueue]);
const removeFromQueue = useCallback((id: string) => { const removeFromQueue = useCallback((id: string) => {
unshuffledQueueRef.current = unshuffledQueueRef.current.filter((track) => track.id !== id);
commitQueue(queueRef.current.filter((track) => track.id !== id)); commitQueue(queueRef.current.filter((track) => track.id !== id));
}, [commitQueue]); }, [commitQueue]);
const clearTracks = useCallback(() => { const clearTracks = useCallback(() => {
audioRef.current?.pause(); audioRef.current?.pause();
tracksRef.current = []; contextRef.current = [];
unshuffledQueueRef.current = [];
historyRef.current = []; historyRef.current = [];
commitQueue([]); commitQueue([]);
commitShuffle(false);
setIsPlaying(false); setIsPlaying(false);
setProgress(0); setProgress(0);
setDuration(0); setDuration(0);
}, [commitQueue]); }, [commitQueue, commitShuffle]);
const replaceTracks = useCallback((items: Track[]) => { const replaceTracks = useCallback((items: Track[]) => {
if (items.length === 0) return; if (items.length === 0) return;
audioRef.current?.pause(); audioRef.current?.pause();
tracksRef.current = items; contextRef.current = items;
unshuffledQueueRef.current = items.slice(1);
historyRef.current = []; historyRef.current = [];
commitCurrent(items[0]); commitCurrent(items[0]);
commitQueue(shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1)); commitQueue(items.slice(1));
commitShuffle(false);
setIsPlaying(false); setIsPlaying(false);
setProgress(0); setProgress(0);
setDuration(items[0].duration); setDuration(items[0].duration);
}, [commitCurrent, commitQueue]); }, [commitCurrent, commitQueue, commitShuffle]);
return { return {
current, current,
@@ -253,6 +291,7 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
isShuffled, isShuffled,
repeatMode, repeatMode,
play, play,
playFromContext,
playCollection, playCollection,
toggle, toggle,
seek, seek,
+28 -1
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Album, Track } from "../types"; import type { Album, Track } from "../types";
import { orderAlbumsByRecentlyAdded, orderTracksByPlayCount, pickRandomTrack } from "./libraryOrdering"; import {
orderAlbumsByRecentlyAdded,
orderAlbumTracks,
orderTracksByPlayCount,
pickRandomTrack,
selectHeavyRotation,
} from "./libraryOrdering";
const album = (id: string, addedAt?: string): Album => ({ const album = (id: string, addedAt?: string): Album => ({
id, id,
@@ -50,6 +56,27 @@ describe("library ordering", () => {
]); ]);
}); });
it("only returns a heavy rotation after several tracks have repeat listens", () => {
expect(selectHeavyRotation([track("one-play", 1), track("repeat-one", 2), track("repeat-two", 4)])).toEqual([]);
expect(selectHeavyRotation([
track("one-play", 1),
track("repeat-one", 2),
track("repeat-two", 4),
track("repeat-three", 3),
]).map(({ id }) => id)).toEqual(["repeat-two", "repeat-three", "repeat-one"]);
});
it("orders album playback by disc and track number", () => {
const tracks = [
{ ...track("disc-two"), discNumber: 2, trackNumber: 1 },
{ ...track("track-two"), discNumber: 1, trackNumber: 2 },
{ ...track("track-one"), discNumber: 1, trackNumber: 1 },
];
expect(orderAlbumTracks(tracks).map(({ id }) => id)).toEqual(["track-one", "track-two", "disc-two"]);
});
it("picks a different spotlight track when another track is available", () => { it("picks a different spotlight track when another track is available", () => {
const tracks = [track("current"), track("middle"), track("last")]; const tracks = [track("current"), track("middle"), track("last")];
+21
View File
@@ -1,5 +1,8 @@
import type { Album, Track } from "../types"; import type { Album, Track } from "../types";
export const minimumHeavyRotationPlays = 2;
export const minimumHeavyRotationTracks = 3;
export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] { export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] {
return albums return albums
.map((album, index) => ({ .map((album, index) => ({
@@ -22,6 +25,24 @@ export function orderTracksByPlayCount(tracks: Track[]): Track[] {
.map(({ track }) => track); .map(({ track }) => track);
} }
export function selectHeavyRotation(tracks: Track[]): Track[] {
const repeatedTracks = orderTracksByPlayCount(
tracks.filter((track) => (track.playCount ?? 0) >= minimumHeavyRotationPlays),
);
return repeatedTracks.length >= minimumHeavyRotationTracks ? repeatedTracks : [];
}
export function orderAlbumTracks(tracks: Track[]): Track[] {
return tracks
.map((track, index) => ({ track, index }))
.sort((left, right) => (
(left.track.discNumber ?? 1) - (right.track.discNumber ?? 1)
|| (left.track.trackNumber ?? Number.MAX_SAFE_INTEGER) - (right.track.trackNumber ?? Number.MAX_SAFE_INTEGER)
|| left.index - right.index
))
.map(({ track }) => track);
}
export function pickRandomTrack( export function pickRandomTrack(
tracks: Track[], tracks: Track[],
currentId?: string, currentId?: string,
+5
View File
@@ -36,6 +36,11 @@ button:disabled { cursor: wait; opacity: 0.65; }
.skip-link:focus { transform: translateY(0); } .skip-link:focus { transform: translateY(0); }
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); height: 100vh; background: var(--canvas); } .app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); height: 100vh; background: var(--canvas); }
.app-bootstrap { position: relative; display: grid; width: 100vw; height: 100vh; place-items: center; align-content: center; gap: 7px; color: var(--muted); background: var(--canvas); }
.app-bootstrap .brand { position: absolute; top: 25px; left: 16px; padding: 0 8px; color: var(--ink); }
.app-bootstrap .library-status-icon { margin: 0 0 13px; }
.app-bootstrap > strong { color: var(--ink); font-size: 0.92rem; font-weight: 600; }
.app-bootstrap > span { color: var(--faint); font-size: 0.7rem; }
.sidebar { position: relative; z-index: 30; display: flex; height: calc(100vh - var(--player-height)); flex-direction: column; min-width: 0; padding: 25px 16px 16px; border-right: 1px solid var(--line); background: oklch(0.15 0.009 275); } .sidebar { position: relative; z-index: 30; display: flex; height: calc(100vh - var(--player-height)); flex-direction: column; min-width: 0; padding: 25px 16px 16px; border-right: 1px solid var(--line); background: oklch(0.15 0.009 275); }
.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 33px; font-size: 1.06rem; font-weight: 700; letter-spacing: -0.03em; } .brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 33px; font-size: 1.06rem; font-weight: 700; letter-spacing: -0.03em; }
+2
View File
@@ -22,6 +22,8 @@ export interface Track {
streamUrl?: string; streamUrl?: string;
favorite?: boolean; favorite?: boolean;
playCount?: number; playCount?: number;
discNumber?: number;
trackNumber?: number;
} }
export interface ServerConfig { export interface ServerConfig {