Files
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- 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
2026-09-25 23:20:57 -05:00

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