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,610 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AudioLines,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
ListMusic,
|
||||
Loader2,
|
||||
Music2,
|
||||
Settings2,
|
||||
Unplug,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../components/Select";
|
||||
import { SpotifyPlayerBar } from "../components/SpotifyPlayerBar";
|
||||
import { LyricsPanel } from "../components/LyricsPanel";
|
||||
import { MobileNav } from "../components/MobileNav";
|
||||
import {
|
||||
getSpotifyPlaylists,
|
||||
type SpotifyPlaylistSummary,
|
||||
} from "../endpoints/spotify-playlists_GET.schema";
|
||||
import { getSpotifySession, type OutputType as SpotifySession } from "../endpoints/spotify-session_GET.schema";
|
||||
import { postSpotifyLogout } from "../endpoints/spotify-logout_POST.schema";
|
||||
import { postSpotifyPlaylist, type OutputType as SpotifyPlaylist } from "../endpoints/spotify-playlist_POST.schema";
|
||||
import { postLyrics, type OutputType as LyricsResult } from "../endpoints/lyrics_POST.schema";
|
||||
import {
|
||||
loadOutputDirectory,
|
||||
saveOutputDirectory,
|
||||
} from "../helpers/directoryStore";
|
||||
import type { WritableDirectoryHandle } from "../helpers/mp3Tags";
|
||||
import { useSpotifyPlayer } from "../helpers/useSpotifyPlayer";
|
||||
import { useSpotifyDownload } from "../helpers/useSpotifyDownload";
|
||||
import { addLibraryHistory } from "../helpers/libraryHistory";
|
||||
import { findDuplicateIdentities } from "../helpers/libraryIdentity";
|
||||
import styles from "./_index.module.css";
|
||||
|
||||
type Track = {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: string;
|
||||
durationMs: number;
|
||||
uri?: string;
|
||||
imageUrl?: string;
|
||||
externalUrl?: string;
|
||||
isLocal?: boolean;
|
||||
isrc?: string;
|
||||
duplicate?: boolean;
|
||||
};
|
||||
|
||||
const placeholderTracks: Track[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Connect Spotify",
|
||||
artist: "Then paste a playlist URL above",
|
||||
album: "Aurora SpotiRip",
|
||||
duration: "0:00",
|
||||
durationMs: 0,
|
||||
},
|
||||
];
|
||||
|
||||
function formatTime(ms: number) {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [playlistUrl, setPlaylistUrl] = useState("");
|
||||
const [directoryName, setDirectoryName] = useState("Music / Aurora Exports");
|
||||
const [tracks, setTracks] = useState<Track[]>(placeholderTracks);
|
||||
const [spotify, setSpotify] = useState<SpotifySession>({ connected: false });
|
||||
const [playlist, setPlaylist] = useState<SpotifyPlaylist>();
|
||||
const [availablePlaylists, setAvailablePlaylists] = useState<SpotifyPlaylistSummary[]>([]);
|
||||
const [trackPage, setTrackPage] = useState(0);
|
||||
const [trackFilter, setTrackFilter] = useState("");
|
||||
const [loadingPlaylist, setLoadingPlaylist] = useState(false);
|
||||
const [message, setMessage] = useState<string>();
|
||||
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();
|
||||
const canDownload = Boolean(spotify.connected && spotify.canDownload);
|
||||
const downloadableTracks = tracks.filter((track) => track.externalUrl && !track.isLocal);
|
||||
const pageSize = 20;
|
||||
const normalizedFilter = trackFilter.trim().toLowerCase();
|
||||
const filteredTracks = normalizedFilter
|
||||
? tracks.filter((track) =>
|
||||
[track.title, track.artist, track.album].some((value) =>
|
||||
value.toLowerCase().includes(normalizedFilter),
|
||||
),
|
||||
)
|
||||
: tracks;
|
||||
const pageCount = Math.max(1, Math.ceil(filteredTracks.length / pageSize));
|
||||
const visibleTracks = filteredTracks.slice(
|
||||
trackPage * pageSize,
|
||||
trackPage * pageSize + pageSize,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getSpotifySession()
|
||||
.then(async (session) => {
|
||||
setSpotify(session);
|
||||
if (session.connected) {
|
||||
try {
|
||||
const result = await getSpotifyPlaylists();
|
||||
setAvailablePlaylists(
|
||||
result.playlists.filter(
|
||||
(entry) => entry.ownerId === session.profile?.id || entry.collaborative,
|
||||
),
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Unable to list your Spotify playlists.",
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((reason) =>
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Unable to read Spotify connection.",
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadOutputDirectory()
|
||||
.then((handle) => {
|
||||
if (handle?.name) setDirectoryName(handle.name);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lyricsOpen || !player.track?.title || !player.track.artist) return;
|
||||
let cancelled = false;
|
||||
setLyricsLoading(true);
|
||||
setLyricsError(undefined);
|
||||
setLyrics(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 chooseFolder() {
|
||||
const picker = (window as typeof window & {
|
||||
showDirectoryPicker?: () => Promise<WritableDirectoryHandle>;
|
||||
}).showDirectoryPicker;
|
||||
|
||||
if (!picker) {
|
||||
setDirectoryName("Browser downloads folder");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await picker();
|
||||
setDirectoryName(handle.name || "Selected folder");
|
||||
await saveOutputDirectory(handle);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function importPlaylist(source = playlistUrl) {
|
||||
const target = source.trim();
|
||||
if (!target) return;
|
||||
if (!spotify.connected) {
|
||||
setError("Connect Spotify before importing a playlist.");
|
||||
return;
|
||||
}
|
||||
setLoadingPlaylist(true);
|
||||
setError(undefined);
|
||||
setMessage(undefined);
|
||||
try {
|
||||
const imported = await postSpotifyPlaylist({ playlistUrl: target });
|
||||
const duplicateIndexes = findDuplicateIdentities(
|
||||
imported.tracks.map((track) => ({
|
||||
isrc: track.isrc,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
})),
|
||||
);
|
||||
setPlaylist(imported);
|
||||
setTrackPage(0);
|
||||
setTracks(
|
||||
imported.tracks.map((track, index) => ({
|
||||
id: index + 1,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: formatTime(track.durationMs),
|
||||
durationMs: track.durationMs,
|
||||
uri: track.uri,
|
||||
imageUrl: track.imageUrl,
|
||||
externalUrl: track.externalUrl,
|
||||
isLocal: track.isLocal,
|
||||
isrc: track.isrc,
|
||||
duplicate: duplicateIndexes.has(index),
|
||||
})),
|
||||
);
|
||||
setMessage(`Loaded ${imported.tracks.length} tracks from “${imported.name}”.`);
|
||||
setPlaylistUrl(target);
|
||||
addLibraryHistory({
|
||||
kind: "playlist",
|
||||
title: imported.name,
|
||||
trackCount: imported.tracks.length,
|
||||
artworkUrl: imported.imageUrl,
|
||||
sourceUrl: imported.externalUrl,
|
||||
});
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Playlist import failed.");
|
||||
} finally {
|
||||
setLoadingPlaylist(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSpotify() {
|
||||
try {
|
||||
await postSpotifyLogout();
|
||||
setSpotify({ connected: false });
|
||||
setPlaylist(undefined);
|
||||
setAvailablePlaylists([]);
|
||||
setTracks(placeholderTracks);
|
||||
setTrackPage(0);
|
||||
setMessage("Spotify disconnected.");
|
||||
setError(undefined);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Unable to disconnect Spotify.");
|
||||
}
|
||||
}
|
||||
|
||||
async function playTrack(track: Track) {
|
||||
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 (
|
||||
<div className={styles.appShell}>
|
||||
|
||||
<main className={styles.main}>
|
||||
<header className={styles.topbar}>
|
||||
<Link to="/" className={styles.mobileBrand} aria-label="Aurora SpotiRip home">
|
||||
<img
|
||||
src="/SpotiRip.png"
|
||||
alt=""
|
||||
/>
|
||||
</Link>
|
||||
<div>
|
||||
<span className={styles.sectionKicker}>PLAYLIST WORKBENCH</span>
|
||||
<h1>Library Queue</h1>
|
||||
</div>
|
||||
<div className={styles.topbarActions}>
|
||||
{spotify.connected ? (
|
||||
<div className={styles.account}>
|
||||
{spotify.profile?.imageUrl ? <img src={spotify.profile.imageUrl} alt="" /> : null}
|
||||
<div>
|
||||
<strong>{spotify.profile?.displayName || "Spotify connected"}</strong>
|
||||
<span>{spotify.profile?.product === "premium" ? "Premium" : spotify.profile?.product || "Spotify"}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={disconnectSpotify} aria-label="Disconnect Spotify">
|
||||
<Unplug size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" asChild><a href="/_api/spotify-auth">Connect Spotify</a></Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" aria-label="Settings" asChild><Link to="/settings"><Settings2 size={18} /></Link></Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.sourceStrip}>
|
||||
<div className={styles.sourceLabel}>
|
||||
<Music2 size={17} />
|
||||
<div>
|
||||
<span>SPOTIFY SOURCE</span>
|
||||
<strong>Import a playlist</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sourceInput}>
|
||||
{spotify.connected && availablePlaylists.length ? (
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
importPlaylist(`https://open.spotify.com/playlist/${value}`)
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label="Choose one of my Spotify playlists">
|
||||
<SelectValue placeholder="My playlists" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availablePlaylists.map((entry) => (
|
||||
<SelectItem key={entry.id} value={entry.id}>
|
||||
{entry.name} ({entry.total})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Input
|
||||
value={playlistUrl}
|
||||
onChange={(event) => setPlaylistUrl(event.target.value)}
|
||||
placeholder="Paste Spotify playlist URL"
|
||||
aria-label="Spotify playlist URL"
|
||||
/>
|
||||
<Button onClick={() => importPlaylist()} disabled={loadingPlaylist}>
|
||||
{loadingPlaylist ? "Loading…" : "Load"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.sourceMeta}>
|
||||
<span>{playlist ? playlist.total : 0} tracks</span>
|
||||
<span>
|
||||
{player.ready
|
||||
? "Player ready"
|
||||
: spotify.connected
|
||||
? "Connecting player"
|
||||
: "Spotify offline"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(error || message || player.error) && (
|
||||
<div className={error || player.error ? styles.noticeError : styles.notice}>
|
||||
{error || player.error || message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={styles.playlistHero}>
|
||||
<div className={styles.coverPanel}>
|
||||
<div className={styles.coverCrop}>
|
||||
<img
|
||||
src={playlist?.imageUrl || "/SpotiRip.png"}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.coverGlow} />
|
||||
</div>
|
||||
|
||||
<div className={styles.playlistSummary}>
|
||||
<span className={styles.sectionKicker}>SPOTIFY PLAYLIST</span>
|
||||
<h2>{playlist?.name || "Import a playlist"}</h2>
|
||||
<p>
|
||||
{playlist?.description ||
|
||||
"Connect Spotify, paste a playlist URL, and SpotiRip will load the real track list and artwork here."}
|
||||
</p>
|
||||
{playlist?.externalUrl ? (
|
||||
<a
|
||||
className={styles.spotifyAttribution}
|
||||
href={playlist.externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Spotify <ExternalLink size={12} /> OPEN SPOTIFY
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
<div className={styles.waveform} aria-hidden="true">
|
||||
{[18, 34, 48, 26, 60, 42, 72, 54, 30, 66, 44, 80, 52, 28, 62, 38, 74, 46, 24, 56, 36, 68, 40, 22].map((height, index) => (
|
||||
<span key={index} style={{ height: height + "%" }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.playlistStats}>
|
||||
<div><strong>{playlist ? playlist.tracks.length : 0}</strong><span>Tracks</span></div>
|
||||
<div><strong>{playlist?.owner || "—"}</strong><span>Owner</span></div>
|
||||
<div><strong>{player.ready ? "Ready" : "—"}</strong><span>Web player</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.quickPanel}>
|
||||
<span className={styles.sectionKicker}>QUICK ACTIONS</span>
|
||||
<button className={styles.quickAction} onClick={chooseFolder}>
|
||||
<FolderOpen size={16} />
|
||||
<div><span>Destination</span><strong>{directoryName}</strong></div>
|
||||
</button>
|
||||
<Link className={styles.quickAction} to="/identify">
|
||||
<AudioLines size={16} />
|
||||
<div><span>Audio tools</span><strong>Identify unknown files</strong></div>
|
||||
</Link>
|
||||
<Link className={styles.quickAction} to="/convert">
|
||||
<Download size={16} />
|
||||
<div><span>Audio tools</span><strong>Convert local audio to MP3</strong></div>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="library" className={styles.libraryPanel}>
|
||||
<div className={styles.libraryToolbar}>
|
||||
<div className={styles.libraryTitle}>
|
||||
<div className={styles.albumMark}><ListMusic size={21} /></div>
|
||||
<div>
|
||||
<span>CURRENT QUEUE</span>
|
||||
<strong>{playlist?.name || "No playlist loaded"}</strong>
|
||||
{playlist?.owner ? <span className={styles.owner}>by {playlist.owner}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.libraryActions}>
|
||||
<Input
|
||||
className={styles.librarySearch}
|
||||
value={trackFilter}
|
||||
onChange={(event) => {
|
||||
setTrackFilter(event.target.value);
|
||||
setTrackPage(0);
|
||||
}}
|
||||
placeholder="Filter tracks"
|
||||
aria-label="Filter playlist tracks"
|
||||
/>
|
||||
<Button variant="outline" size="sm" asChild><Link to="/identify"><AudioLines size={15} /> Identify</Link></Button>
|
||||
<Button size="sm" asChild><Link to="/convert"><Download size={15} /> Convert</Link></Button>
|
||||
{canDownload && playlist && downloadableTracks.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={downloads.busyKeys.has("playlist")}
|
||||
onClick={() =>
|
||||
downloads.startDownload("playlist", `“${playlist.name}”`, {
|
||||
kind: "playlist",
|
||||
name: playlist.name,
|
||||
urls: downloadableTracks.map((track) => track.externalUrl!),
|
||||
})
|
||||
}
|
||||
>
|
||||
{downloads.busyKeys.has("playlist") ? <Loader2 size={15} className={styles.spin} /> : <Download size={15} />}
|
||||
Download playlist (.zip)
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.trackTable}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span>#</span>
|
||||
<span>Title</span>
|
||||
<span>Album</span>
|
||||
<span>Time</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
|
||||
{visibleTracks.map((track) => (
|
||||
<div
|
||||
className={styles.trackRow}
|
||||
key={track.id}
|
||||
role={track.uri ? "button" : undefined}
|
||||
tabIndex={track.uri ? 0 : -1}
|
||||
onClick={() => playTrack(track)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
playTrack(track);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.trackNo}>{String(track.id).padStart(2, "0")}</span>
|
||||
<div className={styles.trackMain}>
|
||||
<div className={styles.trackThumb}>
|
||||
{track.imageUrl ? <img src={track.imageUrl} alt="" /> : <Music2 size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{track.title}</strong>
|
||||
<span>{track.artist}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.album}>{track.album}</span>
|
||||
<span className={styles.duration}>{track.duration}</span>
|
||||
<div className={styles.rowEnd}>
|
||||
<span className={styles.trackStatus}>
|
||||
{track.duplicate ? "Duplicate" : track.isLocal ? "Local item" : track.isrc ? "ISRC" : "Spotify"}
|
||||
</span>
|
||||
{canDownload && track.externalUrl && !track.isLocal ? (
|
||||
<button
|
||||
className={styles.rowDownload}
|
||||
disabled={downloads.busyKeys.has(track.externalUrl)}
|
||||
aria-label={`Download ${track.title} as MP3`}
|
||||
title="Download MP3"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
downloads.startDownload(track.externalUrl!, `“${track.title}”`, {
|
||||
kind: "track",
|
||||
urls: [track.externalUrl!],
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{downloads.busyKeys.has(track.externalUrl) ? <Loader2 size={13} className={styles.spin} /> : <Download size={13} />}
|
||||
</button>
|
||||
) : null}
|
||||
{track.externalUrl ? (
|
||||
<a
|
||||
href={track.externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Open ${track.title} in Spotify`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{playlist && pageCount > 1 ? (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={trackPage === 0}
|
||||
onClick={() => setTrackPage((page) => Math.max(0, page - 1))}
|
||||
>
|
||||
<ChevronLeft size={14} /> Previous
|
||||
</Button>
|
||||
<span>Page {trackPage + 1} of {pageCount}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={trackPage >= pageCount - 1}
|
||||
onClick={() => setTrackPage((page) => Math.min(pageCount - 1, page + 1))}
|
||||
>
|
||||
Next <ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{playlist && filteredTracks.length === 0 ? (
|
||||
<div className={styles.noResults}>No tracks match “{trackFilter}”.</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<footer className={styles.statusbar}>
|
||||
<span>{spotify.connected ? "Spotify connected" : "Spotify disconnected"}</span>
|
||||
<span>{directoryName}</span>
|
||||
<span>ID3 + filename normalization</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<div id="player" 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>
|
||||
{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 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user