- Spotify playlist, library, album and artist browsing with in-app playback - Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks - Electron desktop app (castLabs Widevine) with a permanent sidebar layout - Linux and Windows packaging scripts
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import {
|
|
Album,
|
|
ExternalLink,
|
|
Heart,
|
|
LoaderCircle,
|
|
Mic2,
|
|
Play,
|
|
Podcast,
|
|
} from "lucide-react";
|
|
import { Button } from "../components/Button";
|
|
import { DownloadButton } from "../components/DownloadButton";
|
|
import { useSpotifyDownload } from "../helpers/useSpotifyDownload";
|
|
import { LyricsPanel } from "../components/LyricsPanel";
|
|
import {
|
|
Tabs,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from "../components/Tabs";
|
|
import { SpotifyPlayerBar } from "../components/SpotifyPlayerBar";
|
|
import {
|
|
getSpotifyLibrary,
|
|
type SpotifyLibraryItem,
|
|
type SpotifyLibrarySection,
|
|
} from "../endpoints/spotify-library_GET.schema";
|
|
import {
|
|
getSpotifySession,
|
|
type OutputType as SpotifySession,
|
|
} from "../endpoints/spotify-session_GET.schema";
|
|
import { postLyrics, type OutputType as LyricsResult } from "../endpoints/lyrics_POST.schema";
|
|
import { useSpotifyPlayer } from "../helpers/useSpotifyPlayer";
|
|
import styles from "./spotify-library.module.css";
|
|
import { MobileNav } from "../components/MobileNav";
|
|
|
|
const labels: Record<SpotifyLibrarySection, string> = {
|
|
tracks: "Liked Songs",
|
|
albums: "Saved Albums",
|
|
artists: "Followed Artists",
|
|
shows: "Saved Shows",
|
|
};
|
|
|
|
export default function SpotifyLibraryPage() {
|
|
const [spotify, setSpotify] = useState<SpotifySession>({ connected: false });
|
|
const [section, setSection] = useState<SpotifyLibrarySection>("tracks");
|
|
const [items, setItems] = useState<SpotifyLibraryItem[]>([]);
|
|
const [total, setTotal] = useState<number>();
|
|
const [nextCursor, setNextCursor] = useState<string>();
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string>();
|
|
const [lyricsOpen, setLyricsOpen] = useState(false);
|
|
const [lyricsLoading, setLyricsLoading] = useState(false);
|
|
const [lyrics, setLyrics] = useState<LyricsResult>();
|
|
const [lyricsError, setLyricsError] = useState<string>();
|
|
const player = useSpotifyPlayer(spotify.accessToken);
|
|
const downloads = useSpotifyDownload();
|
|
|
|
useEffect(() => {
|
|
getSpotifySession()
|
|
.then((session) => {
|
|
setSpotify(session);
|
|
if (session.connected) void loadSection("tracks", undefined, false);
|
|
})
|
|
.catch((reason) =>
|
|
setError(reason instanceof Error ? reason.message : "Unable to read Spotify connection."),
|
|
);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!lyricsOpen || !player.track?.title || !player.track.artist) return;
|
|
let cancelled = false;
|
|
setLyricsLoading(true);
|
|
setLyrics(undefined);
|
|
setLyricsError(undefined);
|
|
postLyrics({
|
|
title: player.track.title,
|
|
artist: player.track.artist,
|
|
album: player.track.album || undefined,
|
|
})
|
|
.then((result) => {
|
|
if (!cancelled) setLyrics(result);
|
|
})
|
|
.catch((reason) => {
|
|
if (!cancelled) {
|
|
setLyricsError(reason instanceof Error ? reason.message : "Unable to load lyrics.");
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLyricsLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
lyricsOpen,
|
|
player.track?.uri,
|
|
player.track?.title,
|
|
player.track?.artist,
|
|
player.track?.album,
|
|
]);
|
|
|
|
async function loadSection(
|
|
target: SpotifyLibrarySection,
|
|
cursor?: string,
|
|
append = false,
|
|
) {
|
|
setLoading(true);
|
|
setError(undefined);
|
|
try {
|
|
const result = await getSpotifyLibrary(target, cursor);
|
|
setItems((current) => append ? [...current, ...result.items] : result.items);
|
|
setTotal(result.total);
|
|
setNextCursor(result.nextCursor);
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : "Unable to load Spotify library.");
|
|
if (!append) {
|
|
setItems([]);
|
|
setTotal(undefined);
|
|
setNextCursor(undefined);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function changeSection(value: string) {
|
|
const target = value as SpotifyLibrarySection;
|
|
setSection(target);
|
|
setItems([]);
|
|
setNextCursor(undefined);
|
|
setTotal(undefined);
|
|
void loadSection(target, undefined, false);
|
|
}
|
|
|
|
async function play(item: SpotifyLibraryItem) {
|
|
if (!item.uri || item.type !== "track") return;
|
|
try {
|
|
setError(undefined);
|
|
await player.playUri(item.uri);
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : "Unable to start Spotify playback.");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className={styles.page}>
|
|
<header className={styles.topbar}>
|
|
<span>My Spotify</span>
|
|
</header>
|
|
|
|
<section className={styles.heading}>
|
|
<div>
|
|
<span>SPOTIFY LIBRARY</span>
|
|
<h1>My Spotify</h1>
|
|
<p>Browse the music and shows saved to your connected Spotify account without copying URLs.</p>
|
|
</div>
|
|
{!spotify.connected ? (
|
|
<Button asChild><a href="/_api/spotify-auth">Connect Spotify</a></Button>
|
|
) : (
|
|
<div className={styles.account}>
|
|
{spotify.profile?.imageUrl ? <img src={spotify.profile.imageUrl} alt="" /> : null}
|
|
<div><strong>{spotify.profile?.displayName || "Spotify"}</strong><span>{spotify.profile?.product || "account"}</span></div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{error ? (
|
|
<div className={styles.noticeError}>
|
|
<span>{error}</span>
|
|
{spotify.connected ? <Button size="sm" asChild><a href="/_api/spotify-auth">Reconnect Spotify</a></Button> : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<section className={styles.panel}>
|
|
<div className={styles.toolbar}>
|
|
<Tabs value={section} onValueChange={changeSection}>
|
|
<TabsList>
|
|
<TabsTrigger value="tracks"><Heart size={14} /> Liked Songs</TabsTrigger>
|
|
<TabsTrigger value="albums"><Album size={14} /> Albums</TabsTrigger>
|
|
<TabsTrigger value="artists"><Mic2 size={14} /> Artists</TabsTrigger>
|
|
<TabsTrigger value="shows"><Podcast size={14} /> Shows</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
<div className={styles.count}>
|
|
{typeof total === "number" ? total + " total" : labels[section]}
|
|
</div>
|
|
</div>
|
|
|
|
{section === "tracks" ? (
|
|
<div className={styles.trackList}>
|
|
<div className={styles.trackHeader}>
|
|
<span>#</span><span>Title</span><span>Album</span><span>Added</span><span></span>
|
|
</div>
|
|
{items.map((item, index) => (
|
|
<div className={styles.trackRow} key={item.id}>
|
|
<span className={styles.index}>{String(index + 1).padStart(2, "0")}</span>
|
|
<div className={styles.trackMain}>
|
|
<div className={styles.art}>
|
|
{item.imageUrl ? <img src={item.imageUrl} alt="" /> : <Heart size={16} />}
|
|
</div>
|
|
<div><strong>{item.name}</strong><span>{item.subtitle}</span></div>
|
|
</div>
|
|
<span className={styles.detail}>{item.detail || "—"}</span>
|
|
<span className={styles.added}>{item.addedAt ? new Date(item.addedAt).toLocaleDateString() : "—"}</span>
|
|
<div className={styles.rowActions}>
|
|
{item.externalUrl ? (
|
|
<DownloadButton
|
|
downloads={downloads}
|
|
busyKey={item.externalUrl}
|
|
label={`“${item.name}”`}
|
|
request={{ kind: "track", urls: [item.externalUrl] }}
|
|
/>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
disabled={!player.ready || !item.uri}
|
|
onClick={() => play(item)}
|
|
aria-label={"Play " + item.name}
|
|
>
|
|
<Play size={14} />
|
|
</Button>
|
|
{item.externalUrl ? (
|
|
<Button variant="ghost" size="icon-sm" asChild>
|
|
<a href={item.externalUrl} target="_blank" rel="noreferrer" aria-label={"Open " + item.name + " in Spotify"}>
|
|
<ExternalLink size={14} />
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className={styles.grid}>
|
|
{items.map((item) => (
|
|
<article className={styles.card} key={item.id}>
|
|
<div className={styles.cardArt}>
|
|
{item.imageUrl ? <img src={item.imageUrl} alt="" /> : section === "artists" ? <Mic2 size={28} /> : section === "shows" ? <Podcast size={28} /> : <Album size={28} />}
|
|
</div>
|
|
<div className={styles.cardBody}>
|
|
<strong>{item.name}</strong>
|
|
<span>{item.subtitle || item.detail || "Spotify"}</span>
|
|
{item.total ? <small>{item.total} {section === "shows" ? "episodes" : "tracks"}</small> : null}
|
|
</div>
|
|
<div className={styles.cardActions}>
|
|
{section === "albums" ? (
|
|
<Button size="sm" asChild>
|
|
<Link to={"/album/" + item.id}>Browse album</Link>
|
|
</Button>
|
|
) : null}
|
|
{section === "albums" && item.externalUrl ? (
|
|
<DownloadButton
|
|
downloads={downloads}
|
|
busyKey={item.externalUrl}
|
|
label={`“${item.name}”`}
|
|
request={{
|
|
kind: "playlist",
|
|
urls: [item.externalUrl],
|
|
name: item.subtitle ? `${item.subtitle} - ${item.name}` : item.name,
|
|
}}
|
|
text="Download"
|
|
size="sm"
|
|
variant="outline"
|
|
/>
|
|
) : null}
|
|
{section === "artists" ? (
|
|
<Button size="sm" asChild>
|
|
<Link to={"/artist/" + item.id}>Browse discography</Link>
|
|
</Button>
|
|
) : null}
|
|
{item.externalUrl ? (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={item.externalUrl} target="_blank" rel="noreferrer">Open Spotify <ExternalLink size={13} /></a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!items.length && !loading && spotify.connected && !error ? (
|
|
<div className={styles.empty}>No items found in {labels[section]}.</div>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<div className={styles.loading}><LoaderCircle size={18} /> Loading {labels[section].toLowerCase()}…</div>
|
|
) : null}
|
|
|
|
{nextCursor && !loading ? (
|
|
<div className={styles.loadMore}>
|
|
<Button variant="outline" onClick={() => loadSection(section, nextCursor, true)}>Load more</Button>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
{spotify.connected ? (
|
|
<div className={styles.playerDock}>
|
|
<SpotifyPlayerBar
|
|
connected={spotify.connected}
|
|
ready={player.ready}
|
|
trackTitle={player.track?.title}
|
|
artist={player.track?.artist}
|
|
album={player.track?.album}
|
|
imageUrl={player.track?.imageUrl}
|
|
externalUrl={player.track?.externalUrl}
|
|
isPlaying={player.isPlaying}
|
|
positionMs={player.positionMs}
|
|
durationMs={player.durationMs}
|
|
volume={player.volume}
|
|
error={player.error}
|
|
lyricsOpen={lyricsOpen}
|
|
onToggleLyrics={() => setLyricsOpen((open) => !open)}
|
|
onTogglePlay={() => player.togglePlay()}
|
|
onPrevious={() => player.previousTrack()}
|
|
onNext={() => player.nextTrack()}
|
|
onSeek={(position) => player.seek(position)}
|
|
onVolume={(volume) => player.setVolume(volume)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{lyricsOpen ? (
|
|
<LyricsPanel
|
|
title={player.track?.title}
|
|
artist={player.track?.artist}
|
|
plain={lyrics?.plain}
|
|
synced={lyrics?.synced}
|
|
instrumental={lyrics?.instrumental}
|
|
positionMs={player.positionMs}
|
|
loading={lyricsLoading}
|
|
error={lyricsError}
|
|
onClose={() => setLyricsOpen(false)}
|
|
/>
|
|
) : null}
|
|
<MobileNav />
|
|
</main>
|
|
);
|
|
}
|