Files
Aurora-SpotiRip/pages/album.$id.tsx
T

304 lines
10 KiB
TypeScript
Raw Normal View History

2026-09-25 23:20:57 -05:00
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
ArrowLeft,
Disc3,
ExternalLink,
LoaderCircle,
Play,
} from "lucide-react";
import { Button } from "../components/Button";
import { DownloadButton } from "../components/DownloadButton";
import { useSpotifyDownload } from "../helpers/useSpotifyDownload";
import { LyricsPanel } from "../components/LyricsPanel";
import { SpotifyPlayerBar } from "../components/SpotifyPlayerBar";
import {
getSpotifyAlbum,
type OutputType as SpotifyAlbumResult,
type SpotifyAlbumTrack,
} from "../endpoints/spotify-album_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 "./album.$id.module.css";
import { MobileNav } from "../components/MobileNav";
function formatTime(ms: number) {
const seconds = Math.max(0, Math.floor(ms / 1000));
return Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
}
export default function AlbumPage() {
const params = useParams();
const albumId = params.id || "";
const [spotify, setSpotify] = useState<SpotifySession>({ connected: false });
const [album, setAlbum] = useState<SpotifyAlbumResult>();
const [loading, setLoading] = useState(true);
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(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const session = await getSpotifySession();
if (cancelled) return;
setSpotify(session);
if (!session.connected) {
setError("Connect Spotify to browse and play album tracks.");
return;
}
const result = await getSpotifyAlbum(albumId);
if (!cancelled) setAlbum(result);
} catch (reason) {
if (!cancelled) {
setError(reason instanceof Error ? reason.message : "Unable to load album.");
}
} finally {
if (!cancelled) setLoading(false);
}
}
if (albumId) void load();
return () => {
cancelled = true;
};
}, [albumId]);
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 || album?.album.name,
})
.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,
album?.album.name,
]);
async function play(track: SpotifyAlbumTrack) {
if (!track.uri) return;
try {
setError(undefined);
await player.playUri(track.uri);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Unable to start Spotify playback.");
}
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<Link to="/spotify-library" className={styles.backLink}>
<ArrowLeft size={16} /> My Spotify
</Link>
<span>Spotify Album</span>
</header>
{error ? (
<div className={styles.noticeError}>
<span>{error}</span>
{!spotify.connected ? (
<Button size="sm" asChild>
<a href="/_api/spotify-auth">Connect Spotify</a>
</Button>
) : null}
</div>
) : null}
{loading ? (
<div className={styles.loading}>
<LoaderCircle size={18} /> Loading album…
</div>
) : null}
{album ? (
<>
<section className={styles.albumHeader}>
<div className={styles.cover}>
{album.album.imageUrl ? (
<img src={album.album.imageUrl} alt="" />
) : (
<Disc3 size={38} />
)}
</div>
<div className={styles.albumInfo}>
<span>SPOTIFY ALBUM</span>
<h1>{album.album.name}</h1>
<h2>{album.album.artist}</h2>
<p>
{album.album.releaseDate || "Release date unknown"}
{album.album.totalTracks ? " · " + album.album.totalTracks + " tracks" : ""}
</p>
{album.album.externalUrl ? (
<a
className={styles.spotifyLink}
href={album.album.externalUrl}
target="_blank"
rel="noreferrer"
>
Spotify <ExternalLink size={13} /> Open album
</a>
) : null}
<div className={styles.downloadAlbum}>
<DownloadButton
downloads={downloads}
busyKey={"album:" + album.album.id}
label={`“${album.album.name}”`}
request={{
kind: "playlist",
urls: album.tracks.flatMap((track) => (track.externalUrl ? [track.externalUrl] : [])),
name: `${album.album.artist} - ${album.album.name}`,
}}
text="Download album"
size="sm"
variant="primary"
/>
</div>
</div>
</section>
<section className={styles.trackPanel}>
<div className={styles.tableHeader}>
<span>#</span><span>Title</span><span>Artist</span><span>Time</span><span></span>
</div>
{album.tracks.map((track) => (
<div
className={styles.trackRow}
key={track.id}
role={track.uri ? "button" : undefined}
tabIndex={track.uri ? 0 : -1}
onClick={() => play(track)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void play(track);
}
}}
>
<span className={styles.trackNumber}>
{track.discNumber && track.discNumber > 1
? track.discNumber + "-" + String(track.trackNumber || 0).padStart(2, "0")
: String(track.trackNumber || 0).padStart(2, "0")}
</span>
<div className={styles.trackTitle}>
<strong>{track.name}</strong>
</div>
<span className={styles.artist}>{track.artist}</span>
<span className={styles.duration}>{formatTime(track.durationMs)}</span>
<div className={styles.actions}>
{track.externalUrl ? (
<DownloadButton
downloads={downloads}
busyKey={track.externalUrl}
label={`“${track.name}”`}
request={{ kind: "track", urls: [track.externalUrl] }}
/>
) : null}
<Button
variant="ghost"
size="icon-sm"
disabled={!player.ready || !track.uri}
onClick={(event) => {
event.stopPropagation();
void play(track);
}}
aria-label={"Play " + track.name}
>
<Play size={14} />
</Button>
{track.externalUrl ? (
<Button variant="ghost" size="icon-sm" asChild>
<a
href={track.externalUrl}
target="_blank"
rel="noreferrer"
aria-label={"Open " + track.name + " in Spotify"}
onClick={(event) => event.stopPropagation()}
>
<ExternalLink size={14} />
</a>
</Button>
) : null}
</div>
</div>
))}
</section>
</>
) : null}
{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>
);
}