304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|