225 lines
7.2 KiB
TypeScript
225 lines
7.2 KiB
TypeScript
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: "A–Z" },
|
||
{ 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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|