Load newest albums and complete song pages, add a Songs view, bundle FFmpeg, persist credentials securely, and serve the managed library on the LAN

Hop-State: A_06FNEJ4B52YWW6V4DQKVHXR
Hop-Proposal: R_06FNEJ37KHGP1VCKVNTV95R
Hop-Task: T_06FNEEBRXR47KT7H71N5H68
Hop-Attempt: AT_06FNEEBRXS5YEZ0KZ4MTDZG
This commit is contained in:
2026-07-12 10:03:19 -07:00
committed by Hop
parent 0168767819
commit 73c4eb1857
17 changed files with 1191 additions and 58 deletions
+79 -30
View File
@@ -30,11 +30,13 @@ 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 { fetchAllPages, pollForLibrary } from "./lib/librarySync";
import {
chooseMusicFolder,
getManagedServerStatus,
getSavedCredentials,
isDesktopRuntime,
saveRemotePassword,
startManagedServer,
stopManagedServer,
type ManagedServerStatus,
@@ -59,6 +61,7 @@ function IconButton({ label, children, onClick, active = false, className = "" }
const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
{ view: "home", label: "Listen now", icon: <Home size={18} /> },
{ view: "albums", label: "Albums", icon: <AlbumIcon size={18} /> },
{ view: "songs", label: "Songs", icon: <ListMusic size={18} /> },
{ view: "artists", label: "Artists", icon: <CircleUserRound size={18} /> },
{ view: "favorites", label: "Favorites", icon: <Heart size={18} /> },
];
@@ -93,15 +96,6 @@ export default function App() {
const player = usePlayer(libraryTracks);
const hasLibraryTracks = libraryTracks.length > 0;
useEffect(() => {
getManagedServerStatus().then((status) => {
setManagedStatus(status);
if (status.musicFolder) {
setLocalSetup((current) => ({ ...current, musicFolder: status.musicFolder ?? current.musicFolder }));
}
}).catch(() => undefined);
}, []);
const visibleTracks = useMemo(() => {
const needle = query.trim().toLowerCase();
let result = view === "favorites" ? libraryTracks.filter((track) => favorites.has(track.id)) : libraryTracks;
@@ -122,11 +116,23 @@ export default function App() {
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 [serverAlbums, serverSongs] = await Promise.all([
fetchAllPages(async (offset, size) => {
const response = await requestSubsonic<{ albumList2?: { album?: ServerAlbum[] } }>(config, "getAlbumList2", { type: "newest", size, offset });
return response.albumList2?.album ?? [];
}),
fetchAllPages(async (offset, size) => {
const response = await requestSubsonic<{ searchResult3?: { song?: ServerSong[] } }>(config, "search3", {
query: "",
artistCount: 0,
albumCount: 0,
songCount: size,
songOffset: offset,
});
return response.searchResult3?.song ?? [];
}),
]);
const albums = (albumResponse.albumList2?.album ?? []).map((album, index): Album => ({
const albums = serverAlbums.map((album, index): Album => ({
id: album.id,
title: album.name,
artist: album.artist ?? "Unknown artist",
@@ -134,7 +140,7 @@ export default function App() {
coverTone: tones[index % tones.length],
coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined,
}));
const tracks = (songResponse.randomSongs?.song ?? []).map((song, index): Track => ({
const tracks = serverSongs.map((song, index): Track => ({
id: song.id,
title: song.title,
artist: song.artist ?? "Unknown artist",
@@ -172,6 +178,7 @@ export default function App() {
setConnectionMessage("Reaching your library…");
try {
await loadServerLibrary(server, `Connected as ${server.username}`);
await saveRemotePassword(server.password);
localStorage.setItem("resonant.serverUrl", server.url);
localStorage.setItem("resonant.username", server.username);
localStorage.setItem("resonant.sourceMode", "remote");
@@ -191,14 +198,13 @@ export default function App() {
if (musicFolder) setLocalSetup((current) => ({ ...current, musicFolder }));
};
const startLocalLibrary = async (event: FormEvent) => {
event.preventDefault();
const runLocalLibrary = async (musicFolder: string, password: string) => {
setConnectionStatus("connecting");
setConnectionMessage("Starting your private music server…");
setConnectionMessage("Starting your music server…");
try {
const status = await startManagedServer(localSetup.musicFolder, localSetup.password);
const status = await startManagedServer(musicFolder, password);
setManagedStatus(status);
const config = { url: status.url, username: "admin", password: localSetup.password };
const config = { url: status.url, username: "admin", password };
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {
@@ -221,10 +227,12 @@ export default function App() {
setLibraryPhase("scanning");
setConnectionMessage("Scanning your music folder for real tracks…");
setView("home");
localStorage.setItem("resonant.musicFolder", localSetup.musicFolder);
localStorage.setItem("resonant.musicFolder", musicFolder);
localStorage.setItem("resonant.sourceMode", "local");
const library = await pollForLibrary(() => fetchServerLibrary(config));
const library = await pollForLibrary(() => fetchServerLibrary(config), {
isReady: (snapshot) => snapshot.albums.length > 0 && snapshot.tracks.length > 0,
});
if (!library) {
setLibraryPhase("empty");
setConnectionStatus("error");
@@ -244,6 +252,11 @@ export default function App() {
}
};
const startLocalLibrary = async (event: FormEvent) => {
event.preventDefault();
await runLocalLibrary(localSetup.musicFolder, localSetup.password);
};
const stopLocalLibrary = async () => {
try {
const status = await stopManagedServer();
@@ -260,6 +273,41 @@ export default function App() {
}
};
useEffect(() => {
let active = true;
const restoreLibrary = async () => {
try {
const [status, credentials] = await Promise.all([
getManagedServerStatus(),
getSavedCredentials(),
]);
if (!active) return;
setManagedStatus(status);
const musicFolder = status.musicFolder ?? localStorage.getItem("resonant.musicFolder") ?? "";
const localPassword = credentials.localPassword ?? "";
const remotePassword = credentials.remotePassword ?? "";
setLocalSetup({ musicFolder, password: localPassword });
setServer((current) => ({ ...current, password: remotePassword }));
const savedMode = localStorage.getItem("resonant.sourceMode");
if (savedMode === "local" && musicFolder && localPassword) {
await runLocalLibrary(musicFolder, localPassword);
} else if (savedMode === "remote" && server.url && server.username && remotePassword) {
await loadServerLibrary({ ...server, password: remotePassword }, `Connected as ${server.username}`);
}
} catch (error) {
if (!active) return;
setConnectionStatus("error");
setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored.");
}
};
void restoreLibrary();
return () => {
active = false;
};
}, []);
const goTo = (next: View) => {
setView(next);
setMobileNav(false);
@@ -308,10 +356,11 @@ export default function App() {
{sourceMode === "local" ? (
<form className="connection-form" onSubmit={startLocalLibrary}>
<div className="managed-heading"><div className="managed-icon"><AudioLines size={20} /></div><div><strong>Your music, served privately</strong><p>Resonant includes Navidrome and listens only on this computer.</p></div><span>v{managedStatus?.version ?? "0.61.2"}</span></div>
<div className="managed-heading"><div className="managed-icon"><AudioLines size={20} /></div><div><strong>Your music, ready on every device</strong><p>Resonant includes Navidrome and FFmpeg, with private-network access built in.</p></div><span>v{managedStatus?.version ?? "0.61.2"}</span></div>
<label>Music folder<div className="folder-field"><input required readOnly placeholder="Choose the folder that holds your music" value={localSetup.musicFolder} /><button type="button" className="button-secondary" onClick={pickMusicFolder}><FolderOpen size={16} /> Choose folder</button></div></label>
<label>Local password<input type="password" required minLength={8} autoComplete="new-password" placeholder="At least 8 characters" value={localSetup.password} onChange={(event) => setLocalSetup({ ...localSetup, password: event.target.value })} /><small className="field-hint">Used only to protect the private library on this computer. It stays in memory for this session.</small></label>
<label>Library password<input type="password" required minLength={8} autoComplete="current-password" placeholder="At least 8 characters" value={localSetup.password} onChange={(event) => setLocalSetup({ ...localSetup, password: event.target.value })} /><small className="field-hint">Saved in your operating system credential store and used by your other music apps.</small></label>
<div className={`connection-feedback connection-feedback--${connectionStatus}`} role="status"><span className={`status-dot status-dot--${connectionStatus}`} />{connectionMessage}</div>
{managedStatus?.running && managedStatus.lanUrls.length > 0 && <div className="device-addresses"><strong>Listen from another computer</strong><p>Use this address in Resonant or any OpenSubsonic app on the same private network. Sign in as <b>admin</b> with your library password.</p>{managedStatus.lanUrls.map((url) => <code key={url}>{url}</code>)}</div>}
<div className="form-actions">
{managedStatus?.running && <button type="button" className="button-secondary button-danger" onClick={stopLocalLibrary}><Power size={16} /> Stop server</button>}
<button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button>
@@ -326,7 +375,7 @@ export default function App() {
<div className="form-actions"><button type="button" className="button-secondary" onClick={() => goTo("home")}>Cancel</button><button type="submit" className="button-primary" disabled={connectionStatus === "connecting"}>{connectionStatus === "connecting" ? "Connecting…" : "Connect library"}<ChevronRight size={17} /></button></div>
</form>
)}
<div className="privacy-note"><Disc3 size={22} /><div><strong>{sourceMode === "local" ? "Private by default" : "Your server stays yours"}</strong><p>{sourceMode === "local" ? "The managed server binds to 127.0.0.1, keeps its database in Resonants app data, and stops when Resonant closes." : "Resonant speaks OpenSubsonic directly. Your password stays in memory for this session."}</p></div></div>
<div className="privacy-note"><Disc3 size={22} /><div><strong>{sourceMode === "local" ? "Available on your private network" : "Your server stays yours"}</strong><p>{sourceMode === "local" ? "The managed server accepts devices on your LAN, keeps its database in Resonants app data, and stops when Resonant closes. Avoid exposing port 4533 directly to the internet." : "Resonant speaks OpenSubsonic directly and saves the password in your operating system credential store."}</p></div></div>
</section>
) : (
<div className="library-view">
@@ -346,14 +395,14 @@ export default function App() {
</section>
)}
<section className="content-section" aria-labelledby="albums-heading">
{view !== "songs" && <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" : "Back in rotation"}</span><h2 id="albums-heading">{view === "favorites" ? "Loved records" : query ? "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 ? 18 : 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>}
</section>
{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>}
</section>}
<section className="content-section track-section" aria-labelledby="tracks-heading">
<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>
<div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : "A familiar thread"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}</h2></div><span>{visibleTracks.length} tracks</span></div>
<div className="track-list" role="list">{visibleTracks.slice(0, view === "songs" ? visibleTracks.length : 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>
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { pollForLibrary } from "./librarySync";
import { fetchAllPages, pollForLibrary } from "./librarySync";
describe("pollForLibrary", () => {
it("waits until Navidrome returns indexed tracks", async () => {
@@ -24,4 +24,30 @@ describe("pollForLibrary", () => {
await expect(pollForLibrary(load, { attempts: 3, wait })).resolves.toBeNull();
expect(load).toHaveBeenCalledTimes(3);
});
it("can wait for albums and tracks to finish indexing together", async () => {
const load = vi
.fn()
.mockResolvedValueOnce({ albums: [], tracks: [{ id: "early" }] })
.mockResolvedValueOnce({ albums: [{ id: "album" }], tracks: [{ id: "ready" }] });
await expect(pollForLibrary<{ albums: { id: string }[]; tracks: { id: string }[] }>(load, {
attempts: 2,
wait: async () => undefined,
isReady: (snapshot) => snapshot.albums.length > 0 && snapshot.tracks.length > 0,
})).resolves.toEqual({ albums: [{ id: "album" }], tracks: [{ id: "ready" }] });
});
});
describe("fetchAllPages", () => {
it("loads successive pages until the server returns a partial page", async () => {
const loadPage = vi
.fn()
.mockResolvedValueOnce(["one", "two"])
.mockResolvedValueOnce(["three"]);
await expect(fetchAllPages(loadPage, 2)).resolves.toEqual(["one", "two", "three"]);
expect(loadPage).toHaveBeenNthCalledWith(1, 0, 2);
expect(loadPage).toHaveBeenNthCalledWith(2, 2, 2);
});
});
+23 -3
View File
@@ -2,22 +2,42 @@ export interface LibrarySnapshot<TTrack> {
tracks: readonly TTrack[];
}
interface PollOptions {
export async function fetchAllPages<T>(
loadPage: (offset: number, size: number) => Promise<readonly T[]>,
pageSize = 500,
maxPages = 100,
): Promise<T[]> {
const items: T[] = [];
for (let page = 0; page < maxPages; page += 1) {
const batch = await loadPage(page * pageSize, pageSize);
items.push(...batch);
if (batch.length < pageSize) break;
}
return items;
}
interface PollOptions<TSnapshot> {
attempts?: number;
delayMs?: number;
wait?: (delayMs: number) => Promise<void>;
isReady?: (snapshot: TSnapshot) => boolean;
}
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 = {},
{
attempts = 120,
delayMs = 1_000,
wait = waitFor,
isReady = (snapshot) => snapshot.tracks.length > 0,
}: PollOptions<T> = {},
): Promise<T | null> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const snapshot = await load();
if (snapshot.tracks.length > 0) return snapshot;
if (isReady(snapshot)) return snapshot;
} catch (error) {
if (attempt === attempts - 1) throw error;
}
+6 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getManagedServerStatus, isDesktopRuntime } from "./managedServer";
import { getManagedServerStatus, getSavedCredentials, isDesktopRuntime } from "./managedServer";
describe("managed server bridge", () => {
it("reports the browser preview as a non-desktop runtime", () => {
@@ -10,7 +10,12 @@ describe("managed server bridge", () => {
await expect(getManagedServerStatus()).resolves.toMatchObject({
running: false,
url: "http://127.0.0.1:4533",
lanUrls: [],
version: "0.61.2",
});
});
it("does not expose credentials in the browser preview", async () => {
await expect(getSavedCredentials()).resolves.toEqual({});
});
});
+17 -1
View File
@@ -4,10 +4,16 @@ import { open } from "@tauri-apps/plugin-dialog";
export interface ManagedServerStatus {
running: boolean;
url: string;
lanUrls: string[];
musicFolder?: string;
version: string;
}
export interface SavedCredentials {
localPassword?: string;
remotePassword?: string;
}
export function isDesktopRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
@@ -24,11 +30,21 @@ export async function chooseMusicFolder(): Promise<string | null> {
export async function getManagedServerStatus(): Promise<ManagedServerStatus> {
if (!isDesktopRuntime()) {
return { running: false, url: "http://127.0.0.1:4533", version: "0.61.2" };
return { running: false, url: "http://127.0.0.1:4533", lanUrls: [], version: "0.61.2" };
}
return invoke<ManagedServerStatus>("managed_server_status");
}
export async function getSavedCredentials(): Promise<SavedCredentials> {
if (!isDesktopRuntime()) return {};
return invoke<SavedCredentials>("load_saved_credentials");
}
export async function saveRemotePassword(password: string): Promise<void> {
if (!isDesktopRuntime()) return;
await invoke("save_remote_password", { password });
}
export async function startManagedServer(musicFolder: string, password: string): Promise<ManagedServerStatus> {
if (!isDesktopRuntime()) throw new Error("The managed library is available in the Resonant desktop app.");
return invoke<ManagedServerStatus>("start_managed_server", { musicFolder, password });
+4
View File
@@ -195,6 +195,10 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.connection-feedback { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px; border-radius: 8px; color: var(--muted); background: oklch(0.21 0.012 275); font-size: 0.73rem; }
.connection-feedback--error { color: oklch(0.81 0.09 25); background: oklch(0.28 0.055 24); }
.connection-feedback--connected { color: oklch(0.81 0.08 145); background: oklch(0.26 0.05 145); }
.device-addresses { display: grid; gap: 6px; padding: 14px; border: 1px solid oklch(0.39 0.035 145); border-radius: 10px; background: oklch(0.21 0.025 145); }
.device-addresses strong { font-size: 0.75rem; }
.device-addresses p { margin: 0 0 3px; color: var(--muted); font-size: 0.68rem; line-height: 1.5; }
.device-addresses code { width: fit-content; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: oklch(0.84 0.08 145); background: oklch(0.14 0.012 275); font-size: 0.7rem; user-select: all; }
.button-danger { margin-right: auto; color: oklch(0.78 0.1 25); }
.privacy-note { display: flex; gap: 14px; margin-top: 20px; padding: 7px 14px; color: var(--muted); }
.privacy-note svg { flex: 0 0 auto; margin-top: 3px; color: var(--accent-bright); }
+1 -1
View File
@@ -29,4 +29,4 @@ export interface ServerConfig {
}
export type ConnectionStatus = "demo" | "connecting" | "connected" | "error";
export type View = "home" | "albums" | "artists" | "favorites" | "settings";
export type View = "home" | "albums" | "songs" | "artists" | "favorites" | "settings";