Files
embycovers/frontend/src/pages/navidrome/Library.tsx
T
2026-06-08 00:01:55 +12:00

225 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from "react";
import { apiGet } from "../../api";
import { PageHead, Empty, Loading, fmtDuration } from "../../components/ui";
import { IconDisc, IconMusic, IconSearch, IconPlay } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface Album {
id: string;
name: string;
artist: string;
year?: number;
song_count: number;
duration: number;
cover_url: string | null;
}
interface Song {
id: string;
title: string;
track?: number;
duration: number;
artist: string;
}
interface AlbumDetail extends Album {
songs: Song[];
}
const SORTS = [
{ key: "alphabeticalByName", label: "AZ" },
{ key: "newest", label: "Newest" },
{ key: "recent", label: "Recently Played" },
{ key: "frequent", label: "Most Played" },
{ key: "random", label: "Random" },
];
export default function Library() {
const toast = useToast();
const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null);
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [sort, setSort] = useState("alphabeticalByName");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<AlbumDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
apiGet("/api/navidrome/status").then(setStatus).catch(() => setStatus({ connected: false, configured: false }));
}, []);
const load = useCallback(() => {
setLoading(true);
const q = query.trim();
const path = q
? `/api/navidrome/albums?q=${encodeURIComponent(q)}&size=120`
: `/api/navidrome/albums?type=${sort}&size=120`;
apiGet<{ items: Album[] }>(path)
.then((d) => setAlbums(d.items))
.catch((e) => toast(e.message, "err"))
.finally(() => setLoading(false));
}, [sort, query, toast]);
useEffect(() => {
if (status?.connected) load();
}, [status?.connected, sort]); // eslint-disable-line react-hooks/exhaustive-deps
function openAlbum(id: string) {
setDetailLoading(true);
setSelected(null);
apiGet<AlbumDetail>(`/api/navidrome/album/${id}`)
.then(setSelected)
.catch((e) => toast(e.message, "err"))
.finally(() => setDetailLoading(false));
}
if (status && !status.configured) {
return (
<>
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
<div className="panel">
<Empty icon={<IconMusic />}>
Navidrome is not configured. Set <code>NAVIDROME_URL</code>, <code>NAVIDROME_USER</code> and{" "}
<code>NAVIDROME_PASSWORD</code> and restart the app.
</Empty>
</div>
</>
);
}
if (status && status.configured && !status.connected) {
return (
<>
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
<div className="panel">
<Empty icon={<IconMusic />}>Could not connect to Navidrome. {status.error}</Empty>
</div>
</>
);
}
return (
<>
<PageHead title="Music Library">Browse artists and albums served by your Navidrome instance.</PageHead>
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
<div className="seg">
{SORTS.map((s) => (
<button
key={s.key}
className={`seg-btn ${sort === s.key && !query ? "active" : ""}`}
onClick={() => {
setQuery("");
setSort(s.key);
}}
>
{s.label}
</button>
))}
</div>
<form
className="search-inner"
style={{ width: 280 }}
onSubmit={(e) => {
e.preventDefault();
load();
}}
>
<IconSearch />
<input
className="input"
placeholder="Search albums…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
</div>
{loading ? (
<Loading label="Loading albums…" />
) : albums.length === 0 ? (
<div className="panel">
<Empty icon={<IconDisc />}>No albums found.</Empty>
</div>
) : (
<div className="card-grid">
{albums.map((a) => (
<div key={a.id} className="media-card" onClick={() => openAlbum(a.id)}>
{a.cover_url ? (
<img className="media-cover" src={`${a.cover_url}?size=300`} loading="lazy" alt={a.name} />
) : (
<div className="media-cover" style={{ display: "grid", placeItems: "center" }}>
<IconDisc className="dim" />
</div>
)}
<div className="media-body">
<div className="media-title">{a.name}</div>
<div className="media-sub">
{a.artist}
{a.year ? ` · ${a.year}` : ""}
</div>
</div>
</div>
))}
</div>
)}
{(selected || detailLoading) && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(4,7,11,0.7)",
display: "grid",
placeItems: "center",
zIndex: 50,
padding: 24,
}}
onClick={() => setSelected(null)}
>
<div className="panel" style={{ width: 640, maxWidth: "100%", maxHeight: "86vh", overflow: "auto" }} onClick={(e) => e.stopPropagation()}>
{detailLoading || !selected ? (
<Loading />
) : (
<>
<div className="panel-head">
{selected.cover_url && (
<img
src={`${selected.cover_url}?size=120`}
style={{ width: 64, height: 64, borderRadius: 10, objectFit: "cover" }}
alt=""
/>
)}
<div className="grow">
<h3>{selected.name}</h3>
<div className="sub">
{selected.artist}
{selected.year ? ` · ${selected.year}` : ""} · {selected.song_count} tracks ·{" "}
{fmtDuration(selected.duration)}
</div>
</div>
<button className="btn btn-sm" onClick={() => setSelected(null)}>
Close
</button>
</div>
<table className="data-table">
<tbody>
{selected.songs.map((s) => (
<tr key={s.id}>
<td style={{ width: 36 }} className="cell-sub mono">
{s.track ?? <IconPlay />}
</td>
<td className="cell-strong">{s.title}</td>
<td style={{ textAlign: "right", width: 60 }} className="cell-sub mono">
{fmtDuration(s.duration)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
)}
</>
);
}