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,156 @@
|
||||
import { Captions, ExternalLink, Pause, Play, Radio, SkipBack, SkipForward, Volume2 } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
import { Slider } from "./Slider";
|
||||
import styles from "./SpotifyPlayerBar.module.css";
|
||||
|
||||
type SpotifyPlayerBarProps = {
|
||||
className?: string;
|
||||
connected?: boolean;
|
||||
ready?: boolean;
|
||||
trackTitle?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
imageUrl?: string;
|
||||
externalUrl?: string;
|
||||
isPlaying?: boolean;
|
||||
positionMs?: number;
|
||||
durationMs?: number;
|
||||
volume?: number;
|
||||
error?: string;
|
||||
connectHref?: string;
|
||||
onTogglePlay?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
onSeek?: (positionMs: number) => void;
|
||||
onVolume?: (volume: number) => void;
|
||||
lyricsOpen?: boolean;
|
||||
onToggleLyrics?: () => void;
|
||||
};
|
||||
|
||||
function formatTime(ms: number) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return "0:00";
|
||||
const total = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(total / 60);
|
||||
return `${minutes}:${String(total % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SpotifyPlayerBar({
|
||||
className,
|
||||
connected = false,
|
||||
ready = false,
|
||||
trackTitle,
|
||||
artist,
|
||||
album = "Aurora SpotiRip",
|
||||
imageUrl,
|
||||
externalUrl,
|
||||
isPlaying = false,
|
||||
positionMs = 0,
|
||||
durationMs = 0,
|
||||
volume = 0.7,
|
||||
error,
|
||||
connectHref = "/_api/spotify-auth",
|
||||
onTogglePlay,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSeek,
|
||||
onVolume,
|
||||
lyricsOpen = false,
|
||||
onToggleLyrics,
|
||||
}: SpotifyPlayerBarProps) {
|
||||
const usable = connected && ready;
|
||||
const title = trackTitle || (connected ? "Nothing playing" : "Spotify playback");
|
||||
const subtitle =
|
||||
error ||
|
||||
artist ||
|
||||
(!connected
|
||||
? "Connect your account to activate playback"
|
||||
: ready
|
||||
? "Press play on any song"
|
||||
: "Starting Spotify playback…");
|
||||
return (
|
||||
<section className={`${styles.player} ${className || ""}`} aria-label="Spotify player">
|
||||
<div className={styles.nowPlaying}>
|
||||
<div className={styles.cover}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" /> : <Radio size={20} />}
|
||||
</div>
|
||||
<div className={styles.trackText}>
|
||||
<strong>{title}</strong>
|
||||
<span>{subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.transport}>
|
||||
<div className={styles.controls}>
|
||||
<Button variant="ghost" size="icon-sm" disabled={!usable} onClick={onPrevious} aria-label="Previous track">
|
||||
<SkipBack size={17} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
disabled={!usable}
|
||||
onClick={onTogglePlay}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} fill="currentColor" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" disabled={!usable} onClick={onNext} aria-label="Next track">
|
||||
<SkipForward size={17} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.timeline}>
|
||||
<span>{formatTime(positionMs)}</span>
|
||||
<Slider
|
||||
className={styles.timelineSlider}
|
||||
min={0}
|
||||
max={Math.max(durationMs, 1)}
|
||||
step={1000}
|
||||
value={[Math.min(positionMs, Math.max(durationMs, 1))]}
|
||||
disabled={!usable || durationMs <= 0}
|
||||
onValueCommit={(value) => onSeek?.(value[0] || 0)}
|
||||
/>
|
||||
<span>{formatTime(durationMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.playerMeta}>
|
||||
<div className={styles.albumText}>{album}</div>
|
||||
{connected && externalUrl ? (
|
||||
<a
|
||||
className={styles.spotifyLink}
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Spotify <ExternalLink size={12} />
|
||||
</a>
|
||||
) : null}
|
||||
{connected ? (
|
||||
<>
|
||||
<Button
|
||||
variant={lyricsOpen ? "outline" : "ghost"}
|
||||
size="icon-sm"
|
||||
onClick={onToggleLyrics}
|
||||
aria-label="Toggle lyrics"
|
||||
>
|
||||
<Captions size={16} />
|
||||
</Button>
|
||||
<Volume2 size={17} />
|
||||
<Slider
|
||||
className={styles.volumeSlider}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={[volume]}
|
||||
onValueChange={(value) => onVolume?.(value[0] ?? volume)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" asChild>
|
||||
<a href={connectHref}>
|
||||
Connect Spotify <ExternalLink size={14} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user