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
+3 -1
View File
@@ -35,6 +35,8 @@ npm run desktop:dev
The first desktop run downloads the pinned official Navidrome binary for the current Rust target, verifies its release digest, and places it in Tauri's ignored sidecar build directory. The binary is bundled into packaged apps, not committed to this repository.
The generic Tauri entry point also prepares the sidecar automatically, so `npm run tauri dev` and `npm run tauri build` are supported as well.
To validate the full project:
```sh
@@ -45,7 +47,7 @@ npm run check
Open the library control in the lower-left corner.
- **This computer:** choose a music folder and a local password. Resonant starts its bundled Navidrome process on `127.0.0.1:4533`, stores the Navidrome database under the app data directory, and stops it when Resonant closes.
- **This computer:** choose a music folder and a local password. Resonant starts its bundled Navidrome process on `127.0.0.1:4533`, stores the Navidrome database under the app data directory, and stops it when Resonant closes. The app clears its demo content while Navidrome scans and waits for real indexed tracks before showing the library.
- **Existing server:** enter the URL and credentials for a Navidrome or OpenSubsonic server you already run.
The local password and remote server password are held only in memory. Browser development can preview the interface and connect to servers that allow the development origin, but native folder selection and the managed server require the Tauri desktop app.
+1 -1
View File
@@ -11,7 +11,7 @@
"sidecar:prepare": "node scripts/prepare-navidrome.mjs",
"desktop:dev": "npm run sidecar:prepare && tauri dev",
"desktop:build": "npm run sidecar:prepare && tauri build",
"tauri": "tauri",
"tauri": "npm run sidecar:prepare && tauri",
"check": "npm run sidecar:prepare && npm run test && npm run build && cargo check --manifest-path src-tauri/Cargo.toml"
},
"dependencies": {
+5 -3
View File
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const VERSION = "0.61.2";
const RELEASE_API = `https://api.github.com/repos/navidrome/navidrome/releases/tags/v${VERSION}`;
const targetTriple = execFileSync("rustc", ["--print", "host-tuple"], { encoding: "utf8" }).trim();
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const archives = {
"aarch64-apple-darwin": `navidrome_${VERSION}_darwin_arm64.tar.gz`,
@@ -22,9 +24,9 @@ if (!archiveName) {
}
const extension = targetTriple.includes("windows") ? ".exe" : "";
const outputDirectory = resolve("src-tauri/binaries");
const outputDirectory = join(projectRoot, "src-tauri/binaries");
const outputPath = join(outputDirectory, `navidrome-${targetTriple}${extension}`);
const resourceDirectory = resolve("src-tauri/resources");
const resourceDirectory = join(projectRoot, "src-tauri/resources");
const licensePath = join(resourceDirectory, "navidrome-LICENSE");
await mkdir(outputDirectory, { recursive: true });
+2 -2
View File
@@ -4,9 +4,9 @@
"version": "0.1.0",
"identifier": "xyz.gnosyslabs.resonant",
"build": {
"beforeDevCommand": "npm run dev",
"beforeDevCommand": "npm run sidecar:prepare && npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"beforeBuildCommand": "npm run sidecar:prepare && npm run build",
"frontendDist": "../dist"
},
"app": {
+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)); }