Automatically prepare the bundled Navidrome sidecar and replace demo content with real-library scan states and polling

Hop-State: A_06FNEE4FJ502PSGDYQ3272G
Hop-Proposal: R_06FNED6AR1ZZM7CJH7ETC9G
Hop-Task: T_06FNEAPFW0DYEP35V3BMAHG
Hop-Attempt: AT_06FNEAPFW1QY3KAF1QM61N0
This commit is contained in:
2026-07-12 09:45:52 -07:00
committed by Hop
parent ed99fc7f09
commit 0168767819
9 changed files with 151 additions and 22 deletions
+60 -15
View File
@@ -30,6 +30,7 @@ import {
} from "lucide-react";
import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo";
import { usePlayer } from "./hooks/usePlayer";
import { pollForLibrary } from "./lib/librarySync";
import {
chooseMusicFolder,
getManagedServerStatus,
@@ -64,6 +65,8 @@ const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: string }
interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string }
interface LoadedLibrary { albums: Album[]; tracks: Track[] }
type LibraryPhase = "ready" | "scanning" | "empty";
export default function App() {
const [view, setView] = useState<View>("home");
@@ -75,6 +78,7 @@ export default function App() {
const [favorites, setFavorites] = useState(() => new Set(demoTracks.filter((track) => track.favorite).map((track) => track.id)));
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("demo");
const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library");
const [libraryPhase, setLibraryPhase] = useState<LibraryPhase>("ready");
const [sourceMode, setSourceMode] = useState<"local" | "remote">(() => localStorage.getItem("resonant.sourceMode") === "remote" ? "remote" : "local");
const [managedStatus, setManagedStatus] = useState<ManagedServerStatus | null>(null);
const [localSetup, setLocalSetup] = useState({
@@ -87,6 +91,7 @@ export default function App() {
password: "",
});
const player = usePlayer(libraryTracks);
const hasLibraryTracks = libraryTracks.length > 0;
useEffect(() => {
getManagedServerStatus().then((status) => {
@@ -115,13 +120,13 @@ export default function App() {
return next;
});
const loadServerLibrary = async (config: ServerConfig, connectedMessage: string) => {
const fetchServerLibrary = async (config: ServerConfig): Promise<LoadedLibrary> => {
await requestSubsonic(config, "ping");
const [albumResponse, songResponse] = await Promise.all([
requestSubsonic<{ albumList2?: { album?: ServerAlbum[] } }>(config, "getAlbumList2", { type: "recent", size: 18 }),
requestSubsonic<{ randomSongs?: { song?: ServerSong[] } }>(config, "getRandomSongs", { size: 40 }),
]);
const nextAlbums = (albumResponse.albumList2?.album ?? []).map((album, index): Album => ({
const albums = (albumResponse.albumList2?.album ?? []).map((album, index): Album => ({
id: album.id,
title: album.name,
artist: album.artist ?? "Unknown artist",
@@ -129,7 +134,7 @@ export default function App() {
coverTone: tones[index % tones.length],
coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined,
}));
const nextTracks = (songResponse.randomSongs?.song ?? []).map((song, index): Track => ({
const tracks = (songResponse.randomSongs?.song ?? []).map((song, index): Track => ({
id: song.id,
title: song.title,
artist: song.artist ?? "Unknown artist",
@@ -141,15 +146,23 @@ export default function App() {
streamUrl: streamUrl(config, song.id),
favorite: Boolean(song.starred),
}));
if (nextAlbums.length) setLibraryAlbums(nextAlbums);
if (nextTracks.length) {
setLibraryTracks(nextTracks);
setFavorites(new Set(nextTracks.filter((track) => track.favorite).map((track) => track.id)));
player.replaceTracks(nextTracks);
}
return { albums, tracks };
};
const applyServerLibrary = ({ albums, tracks }: LoadedLibrary) => {
setLibraryAlbums(albums);
setLibraryTracks(tracks);
setFavorites(new Set(tracks.filter((track) => track.favorite).map((track) => track.id)));
if (tracks.length) player.replaceTracks(tracks); else player.clearTracks();
};
const loadServerLibrary = async (config: ServerConfig, connectedMessage: string) => {
const library = await fetchServerLibrary(config);
applyServerLibrary(library);
setServer(config);
setConnectionStatus("connected");
setConnectionMessage(connectedMessage);
setConnectionMessage(library.tracks.length ? connectedMessage : "Connected, but this library has no indexed tracks");
setLibraryPhase(library.tracks.length ? "ready" : "empty");
setView("home");
};
@@ -200,11 +213,32 @@ export default function App() {
}
if (lastError) throw lastError;
await loadServerLibrary(config, "Your local library is ready");
setLibraryAlbums([]);
setLibraryTracks([]);
setFavorites(new Set());
player.clearTracks();
setServer(config);
setLibraryPhase("scanning");
setConnectionMessage("Scanning your music folder for real tracks…");
setView("home");
localStorage.setItem("resonant.musicFolder", localSetup.musicFolder);
localStorage.setItem("resonant.sourceMode", "local");
const library = await pollForLibrary(() => fetchServerLibrary(config));
if (!library) {
setLibraryPhase("empty");
setConnectionStatus("error");
setConnectionMessage("No music was indexed. Check the folder and its file permissions.");
return;
}
applyServerLibrary(library);
setLibraryPhase("ready");
setConnectionStatus("connected");
setConnectionMessage("Your local library is ready");
} catch (error) {
setManagedStatus(await getManagedServerStatus().catch(() => null));
setLibraryPhase("empty");
setConnectionStatus("error");
setConnectionMessage(error instanceof Error ? error.message : "Your local library could not start.");
}
@@ -217,6 +251,7 @@ export default function App() {
setLibraryAlbums(demoAlbums);
setLibraryTracks(demoTracks);
player.replaceTracks(demoTracks);
setLibraryPhase("ready");
setConnectionStatus("demo");
setConnectionMessage("Listening with the demo library");
} catch (error) {
@@ -295,7 +330,16 @@ export default function App() {
</section>
) : (
<div className="library-view">
{view === "home" && !query && (
{libraryPhase !== "ready" ? (
<section className="library-status" aria-live="polite">
<div className={`library-status-icon ${libraryPhase === "scanning" ? "is-scanning" : ""}`}><Disc3 size={27} /></div>
<span className="eyebrow">{libraryPhase === "scanning" ? "Building your library" : "Library needs attention"}</span>
<h1>{libraryPhase === "scanning" ? "Finding your music." : "No tracks found yet."}</h1>
<p>{libraryPhase === "scanning" ? "Navidrome is reading tags, artwork, and album structure. Your real music will appear here as soon as the first tracks are indexed." : "The server is running, but it did not find playable audio in the selected folder. Check the folder location and file permissions, then restart the library."}</p>
{libraryPhase === "scanning" ? <div className="scan-progress"><span /><span /><span /><small>Scanning the selected folder</small></div> : <button type="button" className="button-secondary" onClick={() => goTo("settings")}><Settings size={16} /> Check library source</button>}
</section>
) : <>
{hasLibraryTracks && 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>{player.current.artist} has been in your rotation lately. Start here, then let the queue wander.</p><div className="hero-actions"><button className="button-primary button-primary--light" type="button" onClick={player.toggle}>{player.isPlaying ? <Pause size={17} fill="currentColor" /> : <Play size={17} fill="currentColor" />} {player.isPlaying ? "Pause" : "Play now"}</button><button className="button-quiet" type="button" onClick={() => player.addToQueue(libraryTracks[Math.min(4, libraryTracks.length - 1)])}><Shuffle size={17} /> Mix from here</button></div></div>
<div className="hero-art" aria-hidden="true"><Cover album={player.current} size="large" /><div className="orbit orbit-one" /><div className="orbit orbit-two" /></div>
@@ -311,17 +355,18 @@ export default function App() {
<div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : "A familiar thread"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : "Heavy rotation"}</h2></div><span>{visibleTracks.length} tracks</span></div>
<div className="track-list" role="list">{visibleTracks.slice(0, view === "home" && !query ? 6 : 30).map((track, index) => <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={favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} onClick={() => toggleFavorite(track.id)}><Heart size={16} fill={favorites.has(track.id) ? "currentColor" : "none"} /></IconButton><span className="track-time">{formatTime(track.duration)}</span><IconButton label={`Add ${track.title} to queue`} onClick={() => player.addToQueue(track)}><Plus size={17} /></IconButton></div>)}</div>
</section>
</>}
</div>
)}
</main>
<aside className={`queue-panel ${queueOpen ? "is-open" : ""}`} aria-label="Play queue" aria-hidden={!queueOpen}><div className="queue-header"><div><span className="eyebrow">Up next</span><h2>Your queue</h2></div><IconButton label="Close queue" onClick={() => setQueueOpen(false)}><X size={19} /></IconButton></div><div className="queue-now"><Cover album={player.current} size="medium" /><span>Now playing</span><strong>{player.current.title}</strong><small>{player.current.artist}</small></div><div className="queue-list">{player.queue.map((track, index) => <div className="queue-item" key={`${track.id}-${index}`}><button type="button" onClick={() => player.play(track)}><span>{index + 1}</span><div><strong>{track.title}</strong><small>{track.artist}</small></div></button><IconButton label={`Remove ${track.title} from queue`} onClick={() => player.removeFromQueue(track.id)}><X size={15} /></IconButton></div>)}</div></aside>
{hasLibraryTracks && <aside className={`queue-panel ${queueOpen ? "is-open" : ""}`} aria-label="Play queue" aria-hidden={!queueOpen}><div className="queue-header"><div><span className="eyebrow">Up next</span><h2>Your queue</h2></div><IconButton label="Close queue" onClick={() => setQueueOpen(false)}><X size={19} /></IconButton></div><div className="queue-now"><Cover album={player.current} size="medium" /><span>Now playing</span><strong>{player.current.title}</strong><small>{player.current.artist}</small></div><div className="queue-list">{player.queue.map((track, index) => <div className="queue-item" key={`${track.id}-${index}`}><button type="button" onClick={() => player.play(track)}><span>{index + 1}</span><div><strong>{track.title}</strong><small>{track.artist}</small></div></button><IconButton label={`Remove ${track.title} from queue`} onClick={() => player.removeFromQueue(track.id)}><X size={15} /></IconButton></div>)}</div></aside>}
<footer className="player-bar">
{hasLibraryTracks ? <footer className="player-bar">
<button className="now-playing" type="button" onClick={() => setQueueOpen(true)}><Cover album={player.current} size="small" /><span><strong>{player.current.title}</strong><small>{player.current.artist}</small></span><Heart size={16} className={favorites.has(player.current.id) ? "filled-heart" : ""} fill={favorites.has(player.current.id) ? "currentColor" : "none"} /></button>
<div className="transport"><div className="transport-buttons"><IconButton label="Shuffle"><Shuffle size={16} /></IconButton><IconButton label="Previous track" onClick={player.previous}><SkipBack size={19} fill="currentColor" /></IconButton><button className="play-button" type="button" aria-label={player.isPlaying ? "Pause" : "Play"} onClick={player.toggle}>{player.isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" />}</button><IconButton label="Next track" onClick={player.next}><SkipForward size={19} fill="currentColor" /></IconButton><IconButton label="Repeat"><Radio size={16} /></IconButton></div><div className="scrubber"><span>{formatTime(player.progress)}</span><input type="range" aria-label="Playback position" min="0" max={Math.max(player.duration, 1)} step="1" value={Math.min(player.progress, player.duration)} onChange={(event) => player.seek(Number(event.target.value))} style={{ "--range-progress": `${(player.progress / Math.max(player.duration, 1)) * 100}%` } as React.CSSProperties} /><span>{formatTime(player.duration)}</span></div></div>
<div className="player-tools"><IconButton label="Open queue" active={queueOpen} onClick={() => setQueueOpen(!queueOpen)}><ListMusic size={19} /></IconButton><Volume2 size={18} /><input type="range" aria-label="Volume" min="0" max="1" step="0.01" value={player.volume} onChange={(event) => player.changeVolume(Number(event.target.value))} style={{ "--range-progress": `${player.volume * 100}%` } as React.CSSProperties} /></div>
</footer>
</footer> : <footer className="player-bar player-bar--library-status"><div className="player-library-status"><span className={`status-dot status-dot--${connectionStatus}`} /><div><strong>{libraryPhase === "scanning" ? "Scanning your music" : "No tracks available"}</strong><small>{connectionMessage}</small></div></div><button type="button" className="button-secondary" onClick={() => goTo("settings")}>Library source</button></footer>}
</div>
);
}
+7
View File
@@ -107,6 +107,13 @@ export function usePlayer(initialTracks: Track[]) {
previous,
addToQueue: (track: Track) => setQueue((items) => [...items, track]),
removeFromQueue: (id: string) => setQueue((items) => items.filter((track) => track.id !== id)),
clearTracks: () => {
audioRef.current?.pause();
setQueue([]);
setIsPlaying(false);
setProgress(0);
setDuration(0);
},
replaceTracks: (items: Track[]) => {
if (items.length === 0) return;
setCurrent(items[0]);
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { pollForLibrary } from "./librarySync";
describe("pollForLibrary", () => {
it("waits until Navidrome returns indexed tracks", async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error("server is still starting"))
.mockResolvedValueOnce({ tracks: [] })
.mockResolvedValueOnce({ tracks: [] })
.mockResolvedValueOnce({ tracks: [{ id: "real-track" }] });
const wait = vi.fn().mockResolvedValue(undefined);
await expect(pollForLibrary(load, { attempts: 5, wait })).resolves.toEqual({
tracks: [{ id: "real-track" }],
});
expect(load).toHaveBeenCalledTimes(4);
expect(wait).toHaveBeenCalledTimes(3);
});
it("returns null when the scan never yields a track", async () => {
const load = vi.fn().mockResolvedValue({ tracks: [] });
const wait = vi.fn().mockResolvedValue(undefined);
await expect(pollForLibrary(load, { attempts: 3, wait })).resolves.toBeNull();
expect(load).toHaveBeenCalledTimes(3);
});
});
+27
View File
@@ -0,0 +1,27 @@
export interface LibrarySnapshot<TTrack> {
tracks: readonly TTrack[];
}
interface PollOptions {
attempts?: number;
delayMs?: number;
wait?: (delayMs: number) => Promise<void>;
}
const waitFor = (delayMs: number) => new Promise<void>((resolve) => window.setTimeout(resolve, delayMs));
export async function pollForLibrary<T extends LibrarySnapshot<unknown>>(
load: () => Promise<T>,
{ attempts = 120, delayMs = 1_000, wait = waitFor }: PollOptions = {},
): Promise<T | null> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const snapshot = await load();
if (snapshot.tracks.length > 0) return snapshot;
} catch (error) {
if (attempt === attempts - 1) throw error;
}
if (attempt < attempts - 1) await wait(delayMs);
}
return null;
}
+19
View File
@@ -153,6 +153,18 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.empty-state h3 { margin: 12px 0 3px; color: var(--ink); font-size: 0.9rem; }
.empty-state p { margin: 0; font-size: 0.75rem; }
.library-status { display: grid; min-height: min(610px, calc(100vh - var(--player-height) - 120px)); max-width: 620px; place-items: center; align-content: center; margin: 0 auto; text-align: center; }
.library-status-icon { display: grid; width: 58px; height: 58px; margin-bottom: 22px; place-items: center; border: 1px solid var(--line); border-radius: 50%; color: var(--muted); background: var(--surface); }
.library-status-icon.is-scanning { color: var(--accent-bright); animation: scan-turn 3s linear infinite; }
.library-status h1 { margin: 11px 0 10px; color: var(--ink); font-size: clamp(2rem, 4vw, 3.3rem); font-weight: 600; letter-spacing: -0.055em; }
.library-status p { max-width: 58ch; margin: 0; color: var(--muted); font-size: 0.87rem; line-height: 1.65; }
.library-status > .button-secondary { margin-top: 25px; }
.scan-progress { display: flex; align-items: center; gap: 6px; margin-top: 26px; color: var(--faint); }
.scan-progress > span { width: 5px; height: 5px; border-radius: 50%; background: var(--accent-bright); animation: scan-pulse 1.2s ease-in-out infinite; }
.scan-progress > span:nth-child(2) { animation-delay: 150ms; }
.scan-progress > span:nth-child(3) { animation-delay: 300ms; }
.scan-progress small { margin-left: 6px; font-size: 0.68rem; }
.settings-view { width: min(760px, calc(100% - 64px)); margin: 70px auto 120px; }
.settings-view h1 { margin: 10px 0 9px; font-size: 2.35rem; font-weight: 600; letter-spacing: -0.055em; }
.settings-view > p { max-width: 58ch; margin: 0 0 32px; color: var(--muted); line-height: 1.6; }
@@ -208,6 +220,11 @@ input[type="range"]::-webkit-slider-thumb { width: 10px; height: 10px; appearanc
input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visible::-webkit-slider-thumb { opacity: 1; }
.player-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; color: var(--muted); }
.player-tools input { max-width: 90px; }
.player-bar--library-status { grid-template-columns: minmax(0, 1fr) auto; }
.player-library-status { display: flex; min-width: 0; align-items: center; gap: 12px; }
.player-library-status strong, .player-library-status small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.player-library-status strong { color: var(--ink); font-size: 0.76rem; }
.player-library-status small { max-width: 70vw; margin-top: 3px; color: var(--faint); font-size: 0.65rem; }
.queue-panel { position: fixed; z-index: 45; top: 0; right: 0; bottom: var(--player-height); width: min(370px, 92vw); padding: 25px; border-left: 1px solid var(--line); background: oklch(0.17 0.01 275); box-shadow: -24px 0 70px oklch(0.05 0.016 275 / 0.4); transform: translateX(105%); transition: transform 240ms cubic-bezier(.22, 1, .36, 1); }
.queue-panel.is-open { transform: translateX(0); }
@@ -228,6 +245,8 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib
.nav-scrim { display: none; }
@keyframes pulse { 50% { opacity: 0.45; transform: scale(0.82); } }
@keyframes scan-turn { to { transform: rotate(360deg); } }
@keyframes scan-pulse { 50% { opacity: 0.3; transform: translateY(-2px); } }
@media (max-width: 1080px) {
.album-grid { grid-template-columns: repeat(4, minmax(120px, 1fr)); }