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
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user