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:
@@ -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
@@ -34,7 +34,7 @@ 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, orderTracksByPlayCount, pickRandomTrack } from "./lib/libraryOrdering";
|
||||
import { orderAlbumsByRecentlyAdded, orderAlbumTracks, orderTracksByPlayCount, pickRandomTrack, selectHeavyRotation } from "./lib/libraryOrdering";
|
||||
import { fetchAllPages, pollForLibrary } from "./lib/librarySync";
|
||||
import {
|
||||
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 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[] }
|
||||
type LibraryPhase = "ready" | "scanning" | "empty";
|
||||
|
||||
@@ -117,6 +117,7 @@ export default function App() {
|
||||
const [favoriteAnnouncement, setFavoriteAnnouncement] = useState({ key: 0, message: "" });
|
||||
const [favoriteError, setFavoriteError] = useState<string | null>(null);
|
||||
const favoriteErrorTimer = useRef<number | null>(null);
|
||||
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("demo");
|
||||
const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library");
|
||||
const [libraryPhase, setLibraryPhase] = useState<LibraryPhase>("ready");
|
||||
@@ -163,10 +164,15 @@ export default function App() {
|
||||
return result;
|
||||
}, [favorites, libraryTracks, query, view]);
|
||||
|
||||
const heavyRotationTracks = useMemo(() => selectHeavyRotation(libraryTracks), [libraryTracks]);
|
||||
const hasHeavyRotation = heavyRotationTracks.length > 0;
|
||||
|
||||
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);
|
||||
}, [query, view, visibleTracks]);
|
||||
}, [heavyRotationTracks, query, view, visibleTracks]);
|
||||
|
||||
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;
|
||||
}, [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 visibleArtists = useMemo(() => {
|
||||
@@ -299,6 +315,8 @@ export default function App() {
|
||||
streamUrl: streamUrl(config, song.id),
|
||||
favorite: Boolean(song.starred),
|
||||
playCount: song.playCount ?? 0,
|
||||
discNumber: song.discNumber,
|
||||
trackNumber: song.track,
|
||||
}));
|
||||
return { albums, tracks };
|
||||
};
|
||||
@@ -396,6 +414,11 @@ export default function App() {
|
||||
setConnectionMessage("Your local library is ready");
|
||||
} catch (error) {
|
||||
setManagedStatus(await getManagedServerStatus().catch(() => null));
|
||||
setLibraryAlbums([]);
|
||||
setLibraryTracks([]);
|
||||
setSpotlightTrackId("");
|
||||
commitFavorites(new Set());
|
||||
player.clearTracks();
|
||||
setLibraryPhase("empty");
|
||||
setConnectionStatus("error");
|
||||
setConnectionMessage(error instanceof Error ? error.message : "Your local library could not start.");
|
||||
@@ -428,6 +451,12 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
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 {
|
||||
const [status, credentials] = await Promise.all([
|
||||
getManagedServerStatus(),
|
||||
@@ -442,16 +471,29 @@ export default function App() {
|
||||
setLocalSetup({ musicFolder, password: localPassword });
|
||||
setServer((current) => ({ ...current, password: remotePassword }));
|
||||
|
||||
const savedMode = localStorage.getItem("resonant.sourceMode");
|
||||
if (savedMode === "local" && musicFolder && localPassword) {
|
||||
if (savedMode === "local" && musicFolder) {
|
||||
restoringSavedLibrary = true;
|
||||
if (!localPassword) throw new Error("Your saved library password is unavailable. Open Library source to reconnect it.");
|
||||
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}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
if (restoringSavedLibrary) {
|
||||
setLibraryAlbums([]);
|
||||
setLibraryTracks([]);
|
||||
setSpotlightTrackId("");
|
||||
commitFavorites(new Set());
|
||||
player.clearTracks();
|
||||
setLibraryPhase("empty");
|
||||
}
|
||||
setConnectionStatus("error");
|
||||
setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored.");
|
||||
} finally {
|
||||
if (active) setIsBootstrapping(false);
|
||||
}
|
||||
};
|
||||
void restoreLibrary();
|
||||
@@ -469,6 +511,17 @@ export default function App() {
|
||||
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 (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Skip to library</a>
|
||||
@@ -543,7 +596,7 @@ export default function App() {
|
||||
) : <>
|
||||
{hasLibraryTracks && spotlightTrack && view === "home" && !query && (
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
@@ -554,14 +607,14 @@ export default function App() {
|
||||
</section> : <>
|
||||
{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>
|
||||
{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 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>
|
||||
{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 !== "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>
|
||||
{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>
|
||||
</section>
|
||||
</section>}
|
||||
</>}
|
||||
</>}
|
||||
</div>
|
||||
|
||||
+8
-8
@@ -10,14 +10,14 @@ export const albums: Album[] = [
|
||||
];
|
||||
|
||||
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: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss", playCount: 27 },
|
||||
{ id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true, playCount: 18 },
|
||||
{ id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay", playCount: 5 },
|
||||
{ id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo", playCount: 34 },
|
||||
{ id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand", playCount: 2 },
|
||||
{ id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember", playCount: 21 },
|
||||
{ id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss", playCount: 9 },
|
||||
{ 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", discNumber: 1, trackNumber: 1 },
|
||||
{ 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", discNumber: 1, trackNumber: 1 },
|
||||
{ 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", discNumber: 1, trackNumber: 1 },
|
||||
{ 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", discNumber: 1, trackNumber: 2 },
|
||||
];
|
||||
|
||||
export const formatTime = (seconds: number) => {
|
||||
|
||||
@@ -80,6 +80,41 @@ describe("usePlayer playback modes", () => {
|
||||
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", () => {
|
||||
const { result } = renderHook(() => usePlayer(tracks));
|
||||
|
||||
|
||||
+69
-30
@@ -2,12 +2,18 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback";
|
||||
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) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const onTrackCompletedRef = useRef(onTrackCompleted);
|
||||
const tracksRef = useRef(initialTracks);
|
||||
const contextRef = useRef(initialTracks);
|
||||
const currentRef = useRef<Track>(initialTracks[0]);
|
||||
const queueRef = useRef<Track[]>(initialTracks.slice(1));
|
||||
const unshuffledQueueRef = useRef<Track[]>(initialTracks.slice(1));
|
||||
const historyRef = useRef<Track[]>([]);
|
||||
const shuffleRef = useRef(false);
|
||||
const repeatRef = useRef<RepeatMode>("off");
|
||||
@@ -32,6 +38,11 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
||||
setQueue(items);
|
||||
}, []);
|
||||
|
||||
const commitShuffle = useCallback((enabled: boolean) => {
|
||||
shuffleRef.current = enabled;
|
||||
setIsShuffled(enabled);
|
||||
}, []);
|
||||
|
||||
const restartCurrent = useCallback((resume: boolean) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
@@ -53,13 +64,15 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
||||
|
||||
if (nextTrack) {
|
||||
historyRef.current = [...historyRef.current, currentTrack];
|
||||
unshuffledQueueRef.current = removeFirstTrack(unshuffledQueueRef.current, nextTrack.id);
|
||||
commitQueue(queueRef.current.slice(1));
|
||||
commitCurrent(nextTrack);
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
cycle = shuffleTracks(cycle);
|
||||
if (cycle.length > 1 && cycle[0].id === currentTrack.id) {
|
||||
@@ -75,6 +88,15 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
||||
|
||||
commitCurrent(cycle[0]);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -121,28 +143,46 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
||||
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];
|
||||
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);
|
||||
setIsPlaying(true);
|
||||
}, [commitCurrent, commitQueue]);
|
||||
|
||||
const playCollection = useCallback((items: Track[]) => {
|
||||
const firstTrack = items[0];
|
||||
if (!firstTrack) return;
|
||||
const playFromContext = useCallback((track: Track, items: Track[]) => {
|
||||
const trackIndex = items.findIndex((item) => item.id === track.id);
|
||||
const context = trackIndex >= 0 ? [...items] : [track];
|
||||
const upcoming = trackIndex >= 0 ? items.slice(trackIndex + 1) : [];
|
||||
|
||||
historyRef.current = [];
|
||||
const upcoming = shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1);
|
||||
contextRef.current = context;
|
||||
unshuffledQueueRef.current = upcoming;
|
||||
commitShuffle(false);
|
||||
commitQueue(upcoming);
|
||||
|
||||
if (firstTrack.id === currentRef.current.id) {
|
||||
if (track.id === currentRef.current.id) {
|
||||
void audioRef.current?.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
|
||||
return;
|
||||
}
|
||||
|
||||
commitCurrent(firstTrack);
|
||||
commitCurrent(track);
|
||||
setIsPlaying(true);
|
||||
}, [commitCurrent, commitQueue]);
|
||||
}, [commitCurrent, commitQueue, commitShuffle]);
|
||||
|
||||
const playCollection = useCallback((items: Track[]) => {
|
||||
if (items[0]) playFromContext(items[0], items);
|
||||
}, [playFromContext]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
@@ -177,35 +217,26 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
||||
const previousTrack = historyRef.current.at(-1);
|
||||
if (!previousTrack) return;
|
||||
historyRef.current = historyRef.current.slice(0, -1);
|
||||
unshuffledQueueRef.current = [currentRef.current, ...unshuffledQueueRef.current];
|
||||
commitQueue([currentRef.current, ...queueRef.current]);
|
||||
commitCurrent(previousTrack);
|
||||
}, [commitCurrent, commitQueue]);
|
||||
|
||||
const toggleShuffle = useCallback(() => {
|
||||
const nextShuffled = !shuffleRef.current;
|
||||
shuffleRef.current = nextShuffled;
|
||||
setIsShuffled(nextShuffled);
|
||||
commitShuffle(nextShuffled);
|
||||
|
||||
let upcoming = queueRef.current;
|
||||
if (nextShuffled) {
|
||||
if (upcoming.length === 0) {
|
||||
const playedIds = new Set([...historyRef.current, currentRef.current].map((track) => track.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)) {
|
||||
const shuffled = shuffleTracks(unshuffledQueueRef.current);
|
||||
if (shuffled.length > 1 && shuffled.every((track, index) => track.id === unshuffledQueueRef.current[index].id)) {
|
||||
shuffled.push(shuffled.shift()!);
|
||||
}
|
||||
commitQueue(shuffled);
|
||||
return;
|
||||
}
|
||||
|
||||
const libraryOrder = new Map(tracksRef.current.map((track, index) => [track.id, index]));
|
||||
commitQueue(upcoming
|
||||
.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]);
|
||||
commitQueue(unshuffledQueueRef.current);
|
||||
}, [commitQueue, commitShuffle]);
|
||||
|
||||
const cycleRepeat = useCallback(() => {
|
||||
const nextMode = nextRepeatMode(repeatRef.current);
|
||||
@@ -214,34 +245,41 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
||||
}, []);
|
||||
|
||||
const addToQueue = useCallback((track: Track) => {
|
||||
contextRef.current = [...contextRef.current, track];
|
||||
unshuffledQueueRef.current = [...unshuffledQueueRef.current, track];
|
||||
commitQueue([...queueRef.current, track]);
|
||||
}, [commitQueue]);
|
||||
|
||||
const removeFromQueue = useCallback((id: string) => {
|
||||
unshuffledQueueRef.current = unshuffledQueueRef.current.filter((track) => track.id !== id);
|
||||
commitQueue(queueRef.current.filter((track) => track.id !== id));
|
||||
}, [commitQueue]);
|
||||
|
||||
const clearTracks = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
tracksRef.current = [];
|
||||
contextRef.current = [];
|
||||
unshuffledQueueRef.current = [];
|
||||
historyRef.current = [];
|
||||
commitQueue([]);
|
||||
commitShuffle(false);
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
setDuration(0);
|
||||
}, [commitQueue]);
|
||||
}, [commitQueue, commitShuffle]);
|
||||
|
||||
const replaceTracks = useCallback((items: Track[]) => {
|
||||
if (items.length === 0) return;
|
||||
audioRef.current?.pause();
|
||||
tracksRef.current = items;
|
||||
contextRef.current = items;
|
||||
unshuffledQueueRef.current = items.slice(1);
|
||||
historyRef.current = [];
|
||||
commitCurrent(items[0]);
|
||||
commitQueue(shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1));
|
||||
commitQueue(items.slice(1));
|
||||
commitShuffle(false);
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
setDuration(items[0].duration);
|
||||
}, [commitCurrent, commitQueue]);
|
||||
}, [commitCurrent, commitQueue, commitShuffle]);
|
||||
|
||||
return {
|
||||
current,
|
||||
@@ -253,6 +291,7 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
||||
isShuffled,
|
||||
repeatMode,
|
||||
play,
|
||||
playFromContext,
|
||||
playCollection,
|
||||
toggle,
|
||||
seek,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 => ({
|
||||
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", () => {
|
||||
const tracks = [track("current"), track("middle"), track("last")];
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Album, Track } from "../types";
|
||||
|
||||
export const minimumHeavyRotationPlays = 2;
|
||||
export const minimumHeavyRotationTracks = 3;
|
||||
|
||||
export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] {
|
||||
return albums
|
||||
.map((album, index) => ({
|
||||
@@ -22,6 +25,24 @@ export function orderTracksByPlayCount(tracks: 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(
|
||||
tracks: Track[],
|
||||
currentId?: string,
|
||||
|
||||
@@ -36,6 +36,11 @@ button:disabled { cursor: wait; opacity: 0.65; }
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.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); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 33px; font-size: 1.06rem; font-weight: 700; letter-spacing: -0.03em; }
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Track {
|
||||
streamUrl?: string;
|
||||
favorite?: boolean;
|
||||
playCount?: number;
|
||||
discNumber?: number;
|
||||
trackNumber?: number;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
|
||||
Reference in New Issue
Block a user