From 01687678194f157c5f2c1188e4736be0d35a5b6a Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sun, 12 Jul 2026 09:45:52 -0700 Subject: [PATCH] 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 --- README.md | 4 +- package.json | 2 +- scripts/prepare-navidrome.mjs | 8 ++-- src-tauri/tauri.conf.json | 4 +- src/App.tsx | 75 ++++++++++++++++++++++++++++------- src/hooks/usePlayer.ts | 7 ++++ src/lib/librarySync.test.ts | 27 +++++++++++++ src/lib/librarySync.ts | 27 +++++++++++++ src/styles.css | 19 +++++++++ 9 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 src/lib/librarySync.test.ts create mode 100644 src/lib/librarySync.ts diff --git a/README.md b/README.md index 1f93a79..b1dcb62 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index af8b134..fc5c263 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/prepare-navidrome.mjs b/scripts/prepare-navidrome.mjs index 407532a..8a5ef27 100644 --- a/scripts/prepare-navidrome.mjs +++ b/scripts/prepare-navidrome.mjs @@ -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 }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a5b48a7..e10b258 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index 877e5be..b47d365 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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("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("demo"); const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library"); + const [libraryPhase, setLibraryPhase] = useState("ready"); const [sourceMode, setSourceMode] = useState<"local" | "remote">(() => localStorage.getItem("resonant.sourceMode") === "remote" ? "remote" : "local"); const [managedStatus, setManagedStatus] = useState(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 => { 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() { ) : (
- {view === "home" && !query && ( + {libraryPhase !== "ready" ? ( +
+
+ {libraryPhase === "scanning" ? "Building your library" : "Library needs attention"} +

{libraryPhase === "scanning" ? "Finding your music." : "No tracks found yet."}

+

{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."}

+ {libraryPhase === "scanning" ?
Scanning the selected folder
: } +
+ ) : <> + {hasLibraryTracks && view === "home" && !query && (
Picked from your library

Let it find you again.

{player.current.artist} has been in your rotation lately. Start here, then let the queue wander.

+ }
)} - + {hasLibraryTracks && } -
+ {hasLibraryTracks ?
{formatTime(player.progress)} player.seek(Number(event.target.value))} style={{ "--range-progress": `${(player.progress / Math.max(player.duration, 1)) * 100}%` } as React.CSSProperties} />{formatTime(player.duration)}
setQueueOpen(!queueOpen)}> player.changeVolume(Number(event.target.value))} style={{ "--range-progress": `${player.volume * 100}%` } as React.CSSProperties} />
-
+
:
{libraryPhase === "scanning" ? "Scanning your music" : "No tracks available"}{connectionMessage}
} ); } diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index c708844..ea2130b 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -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]); diff --git a/src/lib/librarySync.test.ts b/src/lib/librarySync.test.ts new file mode 100644 index 0000000..35f010c --- /dev/null +++ b/src/lib/librarySync.test.ts @@ -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); + }); +}); diff --git a/src/lib/librarySync.ts b/src/lib/librarySync.ts new file mode 100644 index 0000000..d6ea934 --- /dev/null +++ b/src/lib/librarySync.ts @@ -0,0 +1,27 @@ +export interface LibrarySnapshot { + tracks: readonly TTrack[]; +} + +interface PollOptions { + attempts?: number; + delayMs?: number; + wait?: (delayMs: number) => Promise; +} + +const waitFor = (delayMs: number) => new Promise((resolve) => window.setTimeout(resolve, delayMs)); + +export async function pollForLibrary>( + load: () => Promise, + { attempts = 120, delayMs = 1_000, wait = waitFor }: PollOptions = {}, +): Promise { + 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; +} diff --git a/src/styles.css b/src/styles.css index 9dcaa07..e4face6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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)); }