|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|