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:
2026-09-25 23:20:57 -05:00
commit 3178b8f4f7
204 changed files with 26045 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+610
View File
@@ -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>
);
}
+54
View File
@@ -0,0 +1,54 @@
.page{min-height:100vh;padding:0 28px 132px;color:var(--foreground);background:var(--background);font-family:var(--font-family-base)}
.topbar,.noticeError,.loading,.albumHeader,.trackPanel{width:min(1120px,100%);margin-inline:auto}
.topbar{min-height:82px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--border)}
.topbar img{width:230px;max-height:60px;object-fit:contain}
.topbar>span{justify-self:end;color:var(--muted-foreground);font:.68rem/1 var(--font-family-monospace)}
.backLink{display:inline-flex;align-items:center;gap:7px;color:var(--muted-foreground);text-decoration:none;font-size:.78rem}
.noticeError{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;padding:9px 11px;color:#f1a6bc;background:#1c1218;border:1px solid #49303a;font-size:.72rem}
.loading{min-height:120px;display:flex;align-items:center;justify-content:center;gap:8px;color:#6f7a86;font-size:.72rem}
.loading svg{animation:spin .9s linear infinite}
.albumHeader{display:grid;grid-template-columns:210px 1fr;gap:26px;align-items:end;padding:28px 0 24px;border-bottom:1px solid var(--border)}
.cover{width:210px;height:210px;display:grid;place-items:center;overflow:hidden;color:#6fcbd4;background:#171a20;border:1px solid #303641}
.cover img{width:100%;height:100%;object-fit:contain;background:#191414}
.albumInfo>span{color:#66727e;font:500 .62rem/1 var(--font-family-monospace);letter-spacing:.12em}
.albumInfo h1{margin:8px 0 5px;font:600 clamp(2rem,4vw,3.4rem)/.96 var(--font-family-display);letter-spacing:-.04em}
.albumInfo h2{margin:0;color:#d8dde3;font:500 1rem/1.2 var(--font-family-display)}
.albumInfo p{margin:8px 0 0;color:#717c88;font-size:.72rem}
.spotifyLink{display:inline-flex;align-items:center;gap:6px;margin-top:14px;color:#a8b1bb;text-decoration:none;font-size:.68rem}
.spotifyLink:hover{color:#fff}
.trackPanel{margin-top:18px;border:1px solid var(--border);background:var(--card)}
.tableHeader,.trackRow{display:grid;grid-template-columns:58px minmax(260px,1.5fr) minmax(180px,1fr) 70px 108px;gap:12px;align-items:center}
.tableHeader{min-height:34px;padding:0 13px;color:#626e7a;background:#0f1116;border-bottom:1px solid var(--border);font:500 .61rem/1 var(--font-family-monospace);text-transform:uppercase;letter-spacing:.08em}
.trackRow{min-height:56px;padding:0 13px;border-bottom:1px solid #242831;cursor:pointer}
.trackRow:last-child{border-bottom:0}
.trackRow:hover,.trackRow:focus-visible{background:#15181f;outline:none;box-shadow:inset 2px 0 0 var(--primary)}
.trackNumber,.duration{color:#68737f;font:.66rem/1 var(--font-family-monospace)}
.trackTitle strong{font-size:.79rem}
.artist{color:#6e7985;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.actions{display:flex;justify-content:flex-end;gap:2px}
.playerDock{position:fixed;left:0;right:0;bottom:0;z-index:25;padding:0 22px 14px;pointer-events:none}
.playerDock>*{pointer-events:auto}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:900px){
.page{padding:0 10px 154px}
.topbar{position:sticky;top:0;z-index:20;min-height:58px;grid-template-columns:auto 1fr;background:rgba(11,12,16,.97)}
.topbar img{width:132px;justify-self:end}.topbar>span{display:none}
.backLink{font-size:0}.backLink svg{width:20px;height:20px}
.albumHeader{grid-template-columns:92px 1fr;gap:12px;padding:16px 2px}
.cover{width:92px;height:92px}
.albumInfo h1{font-size:1.55rem;line-height:1}
.albumInfo h2{font-size:.82rem}
.albumInfo p{font-size:.62rem}
.spotifyLink{margin-top:8px;font-size:.62rem}
.trackPanel{margin-top:10px;border-left:0;border-right:0}
.tableHeader{display:none}
.trackRow{grid-template-columns:34px 1fr auto;gap:7px;min-height:62px;padding:7px 8px}
.artist{grid-column:2;font-size:.62rem}
.duration{display:none}
.actions{grid-column:3;grid-row:1/3}
.playerDock{bottom:68px;padding:6px 7px;background:#0b0c10;border-top:1px solid #242831}
}
.downloadAlbum {
margin-top: 14px;
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+303
View File
@@ -0,0 +1,303 @@
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>
);
}
+194
View File
@@ -0,0 +1,194 @@
.page {
min-height: 100vh;
padding: 0 28px 50px;
color: var(--foreground);
background: var(--background);
font-family: var(--font-family-base);
}
.topbar,
.heading,
.dropPanel,
.results {
width: min(1120px, 100%);
margin-inline: auto;
}
.topbar {
min-height: 82px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
border-bottom: 1px solid var(--border);
}
.topbar img {
width: 240px;
max-height: 60px;
object-fit: contain;
}
.topbar > span {
justify-self: end;
color: var(--muted-foreground);
font: .68rem/1 var(--font-family-monospace);
}
.backLink {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
color: var(--muted-foreground);
text-decoration: none;
font-size: .78rem;
}
.heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
padding: 28px 0 20px;
}
.heading > div > span {
color: #687582;
font: 500 .62rem/1 var(--font-family-monospace);
letter-spacing: .12em;
}
.heading h1 {
margin: 7px 0 6px;
font: 600 2.3rem/1 var(--font-family-display);
}
.heading p {
margin: 0;
color: var(--muted-foreground);
font-size: .8rem;
}
.dropPanel {
padding: 18px;
border: 1px solid var(--border);
background: var(--card);
}
.results {
display: grid;
gap: 12px;
margin-top: 16px;
}
.card {
border: 1px solid var(--border);
background: var(--card);
}
.fileHeader {
min-height: 56px;
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
gap: 8px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.fileHeader > svg {
color: var(--primary);
}
.fileHeader strong,
.fileHeader span {
display: block;
}
.fileHeader strong {
font-size: .78rem;
}
.fileHeader div span {
margin-top: 3px;
color: #68737f;
font-size: .64rem;
}
.status {
color: #8dd4dc;
font-size: .67rem;
}
.error {
padding: 12px 14px;
color: #f1a6bc;
font-size: .72rem;
}
.analysis {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(280px, .8fr);
gap: 16px;
padding: 14px;
}
.spectrogram {
width: 100%;
height: 230px;
display: block;
image-rendering: auto;
background: #0b0d12;
border: 1px solid #282e37;
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
border: 1px solid #282e37;
}
.metrics > div {
min-height: 72px;
padding: 11px;
border-right: 1px solid #282e37;
border-bottom: 1px solid #282e37;
}
.metrics svg {
color: #71808f;
}
.metrics span,
.metrics strong {
display: block;
}
.metrics span {
margin-top: 6px;
color: #697582;
font-size: .62rem;
}
.metrics strong {
margin-top: 3px;
font-size: .75rem;
}
@media (max-width: 900px) {
.page { padding: 0 10px 84px; }
.topbar { position:sticky;top:0;z-index:20;min-height:58px;grid-template-columns:auto 1fr;background:rgba(11,12,16,.97); }
.topbar img { width: 132px; justify-self: end; }
.topbar > span { display: none; }
.backLink{font-size:0}.backLink svg{width:20px;height:20px}
.heading { align-items: stretch; flex-direction: column;gap:10px;padding:18px 2px 14px; }
.heading h1{font-size:1.75rem}.heading p{font-size:.74rem;line-height:1.5}
.dropPanel{padding:10px;border-left:0;border-right:0}
.results{gap:9px;margin-top:10px}
.fileHeader{grid-template-columns:24px 1fr;gap:6px;padding:8px 10px}
.status{grid-column:2}
.analysis { grid-template-columns: 1fr;gap:10px;padding:10px; }
.spectrogram{height:180px}
.metrics{grid-template-columns:repeat(2,1fr)}
.metrics>div{min-height:64px;padding:9px}
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from "react";
import { Activity, FileAudio, Gauge, Trash2, Waves } from "lucide-react";
import { Button } from "../components/Button";
import { FileDropzone } from "../components/FileDropzone";
import { analyzeAudioFile, type AudioAnalysis } from "../helpers/audioAnalyzer";
import styles from "./analyze.module.css";
import { MobileNav } from "../components/MobileNav";
type AnalysisItem = {
id: string;
file: File;
status: "analyzing" | "done" | "error";
analysis?: AudioAnalysis;
error?: string;
};
function formatHz(value: number) {
return value >= 1000 ? `${(value / 1000).toFixed(1)} kHz` : `${Math.round(value)} Hz`;
}
function formatDuration(value: number) {
const seconds = Math.max(0, Math.round(value));
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
function Spectrogram({ data }: { data: number[][] }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas || !data.length || !data[0]?.length) return;
const width = data.length;
const height = data[0].length;
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return;
const image = context.createImageData(width, height);
for (let x = 0; x < width; x += 1) {
for (let y = 0; y < height; y += 1) {
const value = data[x][height - 1 - y] || 0;
const index = (y * width + x) * 4;
image.data[index] = Math.round(20 + 30 * value);
image.data[index + 1] = Math.round(30 + 190 * value);
image.data[index + 2] = Math.round(45 + 205 * value);
image.data[index + 3] = 255;
}
}
context.putImageData(image, 0, 0);
}, [data]);
return <canvas ref={ref} className={styles.spectrogram} aria-label="Audio spectrogram" />;
}
export default function AnalyzePage() {
const [items, setItems] = useState<AnalysisItem[]>([]);
const [working, setWorking] = useState(false);
async function addFiles(files: File[]) {
const added = files.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}`,
file,
status: "analyzing" as const,
}));
setItems((current) => [
...current,
...added.filter((entry) => !current.some((item) => item.id === entry.id)),
]);
setWorking(true);
for (const entry of added) {
try {
const analysis = await analyzeAudioFile(entry.file);
setItems((current) =>
current.map((item) =>
item.id === entry.id ? { ...item, status: "done", analysis } : item,
),
);
} catch (reason) {
setItems((current) =>
current.map((item) =>
item.id === entry.id
? {
...item,
status: "error",
error: reason instanceof Error ? reason.message : "Analysis failed.",
}
: item,
),
);
}
}
setWorking(false);
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<span>Audio Analyzer</span>
</header>
<section className={styles.heading}>
<div>
<span>LOCAL AUDIO TOOLS</span>
<h1>Audio Analyzer</h1>
<p>Inspect frequency content and loudness statistics entirely on this device.</p>
</div>
{items.length ? (
<Button variant="outline" onClick={() => setItems([])} disabled={working}>
<Trash2 size={15} /> Clear all
</Button>
) : null}
</section>
<section className={styles.dropPanel}>
<FileDropzone
accept=".mp3,.wav,.m4a,.aac,.ogg,.flac,audio/*"
maxFiles={30}
maxSize={500 * 1024 * 1024}
onFilesSelected={addFiles}
icon={<Waves size={38} />}
title="Drop audio files to analyze"
subtitle="Spectrogram, peak, RMS, crest factor and estimated frequency cutoff"
/>
</section>
<section className={styles.results}>
{items.map((item) => (
<article className={styles.card} key={item.id}>
<div className={styles.fileHeader}>
<FileAudio size={18} />
<div><strong>{item.file.name}</strong><span>{(item.file.size / 1024 / 1024).toFixed(1)} MB</span></div>
<span className={styles.status}>{item.status === "analyzing" ? "Analyzing…" : item.status === "error" ? "Error" : item.analysis?.qualityLabel}</span>
</div>
{item.error ? <div className={styles.error}>{item.error}</div> : null}
{item.analysis ? (
<div className={styles.analysis}>
<Spectrogram data={item.analysis.spectrogram} />
<div className={styles.metrics}>
<div><Activity size={15} /><span>Peak</span><strong>{item.analysis.peakDbfs.toFixed(1)} dBFS</strong></div>
<div><Activity size={15} /><span>RMS</span><strong>{item.analysis.rmsDbfs.toFixed(1)} dBFS</strong></div>
<div><Gauge size={15} /><span>Crest</span><strong>{item.analysis.crestFactorDb.toFixed(1)} dB</strong></div>
<div><Waves size={15} /><span>Cutoff</span><strong>{formatHz(item.analysis.estimatedCutoffHz)}</strong></div>
<div><Gauge size={15} /><span>Sample rate</span><strong>{formatHz(item.analysis.sampleRate)}</strong></div>
<div><FileAudio size={15} /><span>Duration</span><strong>{formatDuration(item.analysis.durationSeconds)}</strong></div>
</div>
</div>
) : null}
</article>
))}
</section>
<MobileNav />
</main>
);
}
+45
View File
@@ -0,0 +1,45 @@
.page{min-height:100vh;padding:0 28px 50px;color:var(--foreground);background:var(--background);font-family:var(--font-family-base)}
.topbar,.noticeError,.artistHeader,.grid,.loading,.loadMore{width:min(1120px,100%);margin-inline:auto}
.topbar{min-height:82px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--border)}
.topbar img{width:230px;max-height:60px;object-fit:contain}
.topbar>span{justify-self:end;color:var(--muted-foreground);font:.68rem/1 var(--font-family-monospace)}
.backLink{display:inline-flex;align-items:center;gap:7px;color:var(--muted-foreground);text-decoration:none;font-size:.78rem}
.noticeError{margin-top:14px;padding:9px 11px;color:#f1a6bc;background:#1c1218;border:1px solid #49303a;font-size:.72rem}
.artistHeader{display:grid;grid-template-columns:170px 1fr;gap:24px;align-items:center;padding:28px 0 22px;border-bottom:1px solid var(--border)}
.artistImage{width:170px;height:170px;display:grid;place-items:center;overflow:hidden;color:#71c9d2;background:#171a20;border:1px solid #303641}
.artistImage img{width:100%;height:100%;object-fit:contain;background:#191414}
.artistHeader>div:last-child>span{color:#66727e;font:500 .62rem/1 var(--font-family-monospace);letter-spacing:.12em}
.artistHeader h1{margin:7px 0 7px;font:600 clamp(2rem,4vw,3.3rem)/.98 var(--font-family-display);letter-spacing:-.035em}
.artistHeader p{margin:0;color:var(--muted-foreground);font-size:.78rem}
.spotifyLink{display:inline-flex;align-items:center;gap:6px;margin-top:14px;color:#a8b1bb;text-decoration:none;font-size:.68rem}
.spotifyLink:hover{color:#fff}
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;margin-top:18px;background:#242831;border:1px solid #242831}
.card{display:flex;flex-direction:column;min-width:0;padding:12px;background:#111319}
.cover{aspect-ratio:1/1;display:grid;place-items:center;overflow:hidden;color:#6fcbd4;background:#1a1e26;border:1px solid #303641}
.cover img{width:100%;height:100%;object-fit:contain;background:#191414}
.cardBody{min-height:92px;padding:10px 0}
.type{display:block;color:#67737f;font:500 .58rem/1 var(--font-family-monospace);letter-spacing:.09em}
.cardBody strong,.cardBody small{display:block}
.cardBody strong{margin-top:5px;font-size:.79rem;line-height:1.25}
.cardBody small{margin-top:5px;color:#697480;font-size:.64rem}
.cardActions{display:flex;gap:6px;flex-wrap:wrap}
.loading,.loadMore{display:flex;align-items:center;justify-content:center;gap:8px;min-height:64px;color:#6f7a86;font-size:.72rem}
.loading svg{animation:spin .9s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:900px){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:900px){
.page{padding:0 10px 84px}
.topbar{position:sticky;top:0;z-index:20;min-height:58px;grid-template-columns:auto 1fr;background:rgba(11,12,16,.97)}
.topbar img{width:132px;justify-self:end}.topbar>span{display:none}
.backLink{font-size:0}.backLink svg{width:20px;height:20px}
.artistHeader{grid-template-columns:92px 1fr;gap:12px;padding:16px 2px}
.artistImage{width:92px;height:92px}
.artistHeader h1{font-size:1.65rem;line-height:1}
.artistHeader p{font-size:.66rem}
.spotifyLink{margin-top:8px;font-size:.62rem}
.grid{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:10px}
.card{padding:8px}
.cardBody{min-height:70px;padding:8px 0}
.cardBody strong{font-size:.73rem}
.cardActions{display:grid;grid-template-columns:1fr;gap:5px}
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Album, ArrowLeft, ExternalLink, LoaderCircle } from "lucide-react";
import { Button } from "../components/Button";
import { DownloadButton } from "../components/DownloadButton";
import { useSpotifyDownload } from "../helpers/useSpotifyDownload";
import {
getSpotifyArtistAlbums,
type SpotifyArtistAlbum,
type OutputType as ArtistAlbumsResult,
} from "../endpoints/spotify-artist-albums_GET.schema";
import styles from "./artist.$id.module.css";
import { MobileNav } from "../components/MobileNav";
export default function ArtistDiscographyPage() {
const params = useParams();
const artistId = params.id || "";
const [data, setData] = useState<ArtistAlbumsResult>();
const downloads = useSpotifyDownload();
const [albums, setAlbums] = useState<SpotifyArtistAlbum[]>([]);
const [nextOffset, setNextOffset] = useState<number>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
if (!artistId) return;
void load(0, false);
}, [artistId]);
async function load(offset: number, append: boolean) {
if (!artistId) return;
setLoading(true);
setError(undefined);
try {
const result = await getSpotifyArtistAlbums(artistId, offset);
setData(result);
setAlbums((current) => append ? [...current, ...result.albums] : result.albums);
setNextOffset(result.nextOffset);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Unable to load artist discography.");
} finally {
setLoading(false);
}
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<Link to="/spotify-library" className={styles.backLink}>
<ArrowLeft size={16} /> My Spotify
</Link>
<span>Artist Discography</span>
</header>
{error ? <div className={styles.noticeError}>{error}</div> : null}
<section className={styles.artistHeader}>
<div className={styles.artistImage}>
{data?.artist.imageUrl ? (
<img src={data.artist.imageUrl} alt="" />
) : (
<Album size={34} />
)}
</div>
<div>
<span>SPOTIFY ARTIST</span>
<h1>{data?.artist.name || "Artist discography"}</h1>
<p>
{typeof data?.total === "number"
? data.total + " releases returned by Spotify"
: "Albums, singles and compilations"}
</p>
{data?.artist.externalUrl ? (
<a
className={styles.spotifyLink}
href={data.artist.externalUrl}
target="_blank"
rel="noreferrer"
>
Spotify <ExternalLink size={13} /> Open artist
</a>
) : null}
</div>
</section>
<section className={styles.grid}>
{albums.map((album) => (
<article className={styles.card} key={album.id}>
<div className={styles.cover}>
{album.imageUrl ? <img src={album.imageUrl} alt="" /> : <Album size={30} />}
</div>
<div className={styles.cardBody}>
<span className={styles.type}>{(album.albumType || "release").toUpperCase()}</span>
<strong>{album.name}</strong>
<small>
{album.releaseDate || "Date unknown"}
{album.totalTracks ? " · " + album.totalTracks + " tracks" : ""}
</small>
</div>
<div className={styles.cardActions}>
<Button size="sm" asChild>
<Link to={"/album/" + album.id}>Browse album</Link>
</Button>
{album.externalUrl ? (
<DownloadButton
downloads={downloads}
busyKey={album.externalUrl}
label={`“${album.name}”`}
request={{
kind: "playlist",
urls: [album.externalUrl],
name: data?.artist.name ? `${data.artist.name} - ${album.name}` : album.name,
}}
text="Download"
size="sm"
variant="outline"
/>
) : null}
{album.externalUrl ? (
<Button variant="outline" size="sm" asChild>
<a href={album.externalUrl} target="_blank" rel="noreferrer">
Open Spotify <ExternalLink size={13} />
</a>
</Button>
) : null}
</div>
</article>
))}
</section>
{loading ? (
<div className={styles.loading}>
<LoaderCircle size={18} /> Loading releases…
</div>
) : null}
{!loading && nextOffset !== undefined ? (
<div className={styles.loadMore}>
<Button variant="outline" onClick={() => load(nextOffset, true)}>Load more releases</Button>
</div>
) : null}
<MobileNav />
</main>
);
}
+197
View File
@@ -0,0 +1,197 @@
.page {
min-height: 100vh;
padding: 0 28px 50px;
color: var(--foreground);
background: var(--background);
font-family: var(--font-family-base);
}
.topbar,
.heading,
.notice,
.panel {
width: min(1100px, 100%);
margin-inline: auto;
}
.topbar {
min-height: 82px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
border-bottom: 1px solid var(--border);
}
.topbar img {
width: 240px;
max-height: 60px;
object-fit: contain;
}
.topbar > span {
justify-self: end;
color: var(--muted-foreground);
font: .68rem/1 var(--font-family-monospace);
}
.backLink {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
color: var(--muted-foreground);
text-decoration: none;
font-size: .78rem;
}
.heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
padding: 28px 0 20px;
}
.heading > div:first-child > span {
color: #687582;
font: 500 .62rem/1 var(--font-family-monospace);
letter-spacing: .12em;
}
.heading h1 {
margin: 7px 0 6px;
font: 600 2.3rem/1 var(--font-family-display);
}
.heading p {
margin: 0;
color: var(--muted-foreground);
font-size: .8rem;
}
.controls,
.bitrate {
display: flex;
align-items: center;
gap: 8px;
}
.bitrate {
color: #75818e;
}
.bitrate > div {
width: 120px;
}
.notice {
margin-bottom: 12px;
padding: 9px 11px;
color: #9bcdd2;
background: #121a1d;
border: 1px solid #2f3a42;
font-size: .72rem;
}
.panel {
padding: 18px;
background: var(--card);
border: 1px solid var(--border);
}
.toolbar {
min-height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
border-bottom: 1px solid var(--border);
}
.toolbar > span {
color: #78838f;
font-size: .7rem;
}
.list {
margin: 0 -18px -18px;
}
.row {
min-height: 60px;
display: grid;
grid-template-columns: 44px 1fr minmax(180px, .5fr);
gap: 12px;
align-items: center;
padding: 0 18px;
border-bottom: 1px solid #242831;
}
.row:last-child {
border-bottom: 0;
}
.index {
color: #5f6b78;
font: .68rem/1 var(--font-family-monospace);
}
.row strong,
.row span {
display: block;
}
.row strong {
font-size: .78rem;
}
.row div > span {
margin-top: 3px;
color: #697480;
font-size: .64rem;
}
.state {
overflow: hidden;
color: #76818d;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
font-size: .68rem;
}
.state svg {
display: inline;
vertical-align: -2px;
color: var(--success);
}
.empty {
padding: 34px 18px;
color: #697480;
text-align: center;
font-size: .72rem;
}
@media (max-width: 900px) {
.page { padding: 0 10px 84px; }
.topbar { position: sticky; top: 0; z-index: 20; min-height: 58px; grid-template-columns: auto 1fr; background: rgba(11,12,16,.97); }
.topbar img { width: 132px; justify-self: end; }
.topbar > span { display: none; }
.backLink { font-size: 0; }
.backLink svg { width: 20px; height: 20px; }
.heading { align-items: stretch; flex-direction: column; gap: 12px; padding: 18px 2px 14px; }
.heading h1 { font-size: 1.75rem; }
.heading p { font-size: .75rem; line-height: 1.5; }
.controls { display: grid; grid-template-columns: 1fr; align-items: stretch; gap: 8px; }
.bitrate { justify-content: space-between; }
.bitrate > div { width: 145px; }
.controls button { width: 100%; }
.panel { padding: 10px; border-left: 0; border-right: 0; }
.toolbar { align-items: stretch; flex-direction: column; padding: 10px 0; }
.toolbar button { width: 100%; }
.list { margin: 0 -10px -10px; }
.row { grid-template-columns: 1fr; gap: 5px; padding: 10px; }
.index { display: none; }
.state { text-align: left; }
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from "react";
import { CheckCircle2, FileAudio, FolderOpen, Gauge, Music2 } from "lucide-react";
import { Button } from "../components/Button";
import { FileDropzone } from "../components/FileDropzone";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/Select";
import { convertAudioFileToMp3 } from "../helpers/audioProcessing";
import {
downloadBlob,
sanitizeFilename,
saveBlobToDirectory,
type WritableDirectoryHandle,
} from "../helpers/mp3Tags";
import {
ensureDirectoryWritePermission,
loadOutputDirectory,
saveOutputDirectory,
} from "../helpers/directoryStore";
import { loadSpotiRipSettings } from "../helpers/spotiripSettings";
import { addLibraryHistory } from "../helpers/libraryHistory";
import styles from "./convert.module.css";
import { MobileNav } from "../components/MobileNav";
type ConvertItem = {
id: string;
file: File;
status: "ready" | "converting" | "saved" | "error";
output?: string;
error?: string;
};
export default function ConvertPage() {
const [settings] = useState(() => loadSpotiRipSettings());
const [items, setItems] = useState<ConvertItem[]>([]);
const [bitrate, setBitrate] = useState(settings.mp3Bitrate);
const [directory, setDirectory] = useState<WritableDirectoryHandle>();
const [working, setWorking] = useState(false);
const [message, setMessage] = useState<string>();
useEffect(() => {
loadOutputDirectory().then(setDirectory).catch(() => undefined);
}, []);
const readyCount = useMemo(() => items.filter((item) => item.status === "ready" || item.status === "error").length, [items]);
function addFiles(files: File[]) {
setItems((current) => [
...current,
...files
.filter((file) => !current.some((item) => item.id === `${file.name}-${file.size}-${file.lastModified}`))
.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}`,
file,
status: "ready" as const,
})),
]);
}
async function chooseFolder() {
const picker = (window as typeof window & {
showDirectoryPicker?: () => Promise<WritableDirectoryHandle>;
}).showDirectoryPicker;
if (!picker) {
setMessage("Direct folder writing is unavailable in this browser; normal downloads will be used.");
return;
}
try {
const selected = await picker();
setDirectory(selected);
await saveOutputDirectory(selected);
setMessage(`Output folder: ${selected.name}`);
} catch {
}
}
async function convertAll() {
const pending = items.filter((item) => item.status === "ready" || item.status === "error");
if (!pending.length || working) return;
setWorking(true);
setMessage(undefined);
let activeDirectory = directory;
if (activeDirectory && !(await ensureDirectoryWritePermission(activeDirectory))) {
activeDirectory = undefined;
setDirectory(undefined);
setMessage("Folder permission was not granted; converted files will download normally.");
}
let completed = 0;
for (const item of pending) {
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "converting", error: undefined } : entry));
try {
const mp3 = await convertAudioFileToMp3(item.file, bitrate);
const base = sanitizeFilename(item.file.name.replace(/\.[^.]+$/, "")) || "Converted Audio";
const filename = base + ".mp3";
const output = activeDirectory
? await saveBlobToDirectory(activeDirectory, filename, mp3)
: (downloadBlob(filename, mp3), filename);
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "saved", output } : entry));
completed += 1;
} catch (reason) {
const detail = reason instanceof Error ? reason.message : "Conversion failed.";
setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "error", error: detail } : entry));
}
}
if (completed > 0) {
addLibraryHistory({
kind: "convert",
title: `Converted ${completed} file${completed === 1 ? "" : "s"} to MP3`,
trackCount: completed,
});
setMessage(
activeDirectory
? `Converted ${completed} file${completed === 1 ? "" : "s"} to “${activeDirectory.name}”.`
: `Converted ${completed} file${completed === 1 ? "" : "s"} and prepared browser downloads.`,
);
}
setWorking(false);
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<span>Local Converter</span>
</header>
<section className={styles.heading}>
<div>
<span>LOCAL AUDIO TOOLS</span>
<h1>Convert to MP3</h1>
<p>Convert audio files on your device without uploading the full source file to SpotiRip.</p>
</div>
<div className={styles.controls}>
<div className={styles.bitrate}>
<Gauge size={16} />
<Select value={String(bitrate)} onValueChange={(value) => setBitrate(Number(value) as 128 | 192 | 256 | 320)}>
<SelectTrigger aria-label="MP3 bitrate"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="128">128 kbps</SelectItem>
<SelectItem value="192">192 kbps</SelectItem>
<SelectItem value="256">256 kbps</SelectItem>
<SelectItem value="320">320 kbps</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={chooseFolder}><FolderOpen size={16} /> {directory?.name || "Output folder"}</Button>
</div>
</section>
{message ? <div className={styles.notice}>{message}</div> : null}
<section className={styles.panel}>
<FileDropzone
accept=".wav,.m4a,.aac,.ogg,.mp3,audio/wav,audio/mp4,audio/aac,audio/ogg,audio/mpeg"
maxFiles={100}
maxSize={250 * 1024 * 1024}
onFilesSelected={addFiles}
icon={<FileAudio size={38} />}
title="Drop audio files here"
subtitle="WAV, M4A, AAC, OGG or MP3 · conversion happens on this device"
/>
<div className={styles.toolbar}>
<span>{items.length} file{items.length === 1 ? "" : "s"} · {readyCount} waiting</span>
<Button disabled={!readyCount || working} onClick={convertAll}>
<Music2 size={16} /> {working ? "Converting…" : "Convert to MP3"}
</Button>
</div>
<div className={styles.list}>
{items.map((item, index) => (
<div className={styles.row} key={item.id}>
<span className={styles.index}>{String(index + 1).padStart(2, "0")}</span>
<div>
<strong>{item.file.name}</strong>
<span>{(item.file.size / 1024 / 1024).toFixed(1)} MB</span>
</div>
<div className={styles.state}>
{item.status === "saved" ? <><CheckCircle2 size={14} /> {item.output}</> :
item.status === "converting" ? "Converting…" :
item.status === "error" ? item.error : "Ready"}
</div>
</div>
))}
{!items.length ? <div className={styles.empty}>Add audio files above to create an MP3 conversion queue.</div> : null}
</div>
</section>
<MobileNav />
</main>
);
}
+261
View File
@@ -0,0 +1,261 @@
.page {
min-height: 100vh;
padding: 0 28px 50px;
color: var(--foreground);
background: var(--background);
font-family: var(--font-family-base);
}
.topbar,
.heading,
.panel {
width: min(1180px, 100%);
margin-inline: auto;
}
.topbar {
min-height: 82px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
border-bottom: 1px solid var(--border);
}
.topbar img {
width: 240px;
max-height: 60px;
object-fit: contain;
}
.topbar > span {
justify-self: end;
color: var(--muted-foreground);
font: .68rem/1 var(--font-family-monospace);
}
.backLink {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
color: var(--muted-foreground);
text-decoration: none;
font-size: .78rem;
}
.heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 0 20px;
}
.heading > div:first-child > span {
color: #687582;
font: 500 .62rem/1 var(--font-family-monospace);
letter-spacing: .12em;
}
.heading h1 {
margin: 7px 0 6px;
font: 600 2.25rem/1 var(--font-family-display);
}
.heading p {
max-width: 680px;
margin: 0;
color: var(--muted-foreground);
font-size: .8rem;
}
.actions,
.searchBox {
display: flex;
align-items: center;
gap: 8px;
}
.searchBox {
min-width: 230px;
color: #687582;
}
.panel {
border: 1px solid var(--border);
background: var(--card);
}
.summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-bottom: 1px solid var(--border);
}
.summary > div {
padding: 14px 16px;
border-right: 1px solid var(--border);
}
.summary > div:last-child {
border-right: 0;
}
.summary strong,
.summary span {
display: block;
}
.summary strong {
font: 600 1rem/1 var(--font-family-display);
}
.summary span {
margin-top: 4px;
color: #687582;
font-size: .63rem;
}
.tableHeader,
.row {
display: grid;
grid-template-columns: minmax(260px, 1.5fr) minmax(180px, 1fr) 150px 190px;
align-items: center;
gap: 12px;
}
.tableHeader {
min-height: 34px;
padding: 0 14px;
color: #697582;
background: #0f1116;
border-bottom: 1px solid #252932;
font: 500 .62rem/1 var(--font-family-monospace);
text-transform: uppercase;
}
.row {
min-height: 62px;
padding: 0 14px;
border-bottom: 1px solid #242831;
}
.track {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.art {
width: 34px;
height: 34px;
display: grid;
place-items: center;
flex: 0 0 auto;
overflow: hidden;
color: #71808f;
background: #1a1e26;
border: 1px solid #303641;
}
.art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.track strong,
.track span {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track strong {
font-size: .76rem;
}
.track span,
.row > span {
margin-top: 3px;
color: #737e8a;
font-size: .67rem;
}
.mono {
font-family: var(--font-family-monospace);
}
.saved {
display: flex;
align-items: center;
gap: 6px;
color: #727d89;
font-size: .65rem;
}
.empty {
min-height: 220px;
display: grid;
place-items: center;
align-content: center;
gap: 7px;
color: #687582;
text-align: center;
}
.empty strong {
color: #dce2e7;
font-size: .82rem;
}
.empty span {
max-width: 440px;
font-size: .7rem;
}
@media (max-width: 820px) {
.heading {
align-items: stretch;
flex-direction: column;
}
.actions {
align-items: stretch;
flex-direction: column;
}
.tableHeader {
display: none;
}
.row {
grid-template-columns: 1fr;
gap: 6px;
padding-block: 12px;
}
}
@media (max-width: 900px) {
.page { padding: 0 10px 84px; }
.topbar { position: sticky; top: 0; z-index: 20; min-height: 58px; grid-template-columns: auto 1fr; background: rgba(11,12,16,.97); }
.topbar img { width: 132px; justify-self: end; }
.topbar > span { display: none; }
.backLink { font-size: 0; }
.backLink svg { width: 20px; height: 20px; }
.heading { gap: 12px; padding: 18px 2px 14px; }
.heading h1 { font-size: 1.75rem; }
.heading p { font-size: .74rem; line-height: 1.5; }
.actions { gap: 8px; }
.actions button { width: 100%; }
.searchBox { min-width: 0; width: 100%; }
.summary { grid-template-columns: repeat(3, 1fr); }
.summary > div { padding: 10px 8px; border-bottom: 0; text-align: center; }
.summary strong { font-size: .9rem; }
.summary span { font-size: .56rem; }
.panel { border-left: 0; border-right: 0; }
.row { padding: 11px 8px; gap: 4px; }
.mono { font-size: .58rem !important; }
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useMemo, useState } from "react";
import {
Clock3,
CopyCheck,
FileAudio,
Search,
Trash2,
} from "lucide-react";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import {
clearLibraryHistory,
listLibraryHistory,
type LibraryTrackRecord,
} from "../helpers/libraryIndex";
import styles from "./history.module.css";
import { MobileNav } from "../components/MobileNav";
export default function HistoryPage() {
const [records, setRecords] = useState<LibraryTrackRecord[]>([]);
const [filter, setFilter] = useState("");
useEffect(() => {
listLibraryHistory().then(setRecords).catch(() => setRecords([]));
}, []);
const visible = useMemo(() => {
const query = filter.trim().toLowerCase();
if (!query) return records;
return records.filter((record) =>
[record.title, record.artist, record.album || "", record.isrc || ""]
.some((value) => value.toLowerCase().includes(query)),
);
}, [records, filter]);
async function clearHistory() {
await clearLibraryHistory();
setRecords([]);
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<span>Library History</span>
</header>
<section className={styles.heading}>
<div>
<span>LOCAL LIBRARY</span>
<h1>History & Dedup Index</h1>
<p>Tracks SpotiRip has repaired or saved on this device, including ISRC identities used for duplicate detection.</p>
</div>
<div className={styles.actions}>
<div className={styles.searchBox}><Search size={15} /><Input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder="Filter history" /></div>
<Button variant="outline" onClick={clearHistory} disabled={!records.length}><Trash2 size={15} /> Clear history</Button>
</div>
</section>
<section className={styles.panel}>
<div className={styles.summary}>
<div><strong>{records.length}</strong><span>Indexed tracks</span></div>
<div><strong>{records.filter((record) => record.isrc).length}</strong><span>ISRC identified</span></div>
<div><strong>{new Set(records.map((record) => record.artist)).size}</strong><span>Artists</span></div>
</div>
<div className={styles.tableHeader}>
<span>Track</span><span>Album</span><span>ISRC</span><span>Saved</span>
</div>
{visible.map((record) => (
<div className={styles.row} key={record.id}>
<div className={styles.track}>
<div className={styles.art}>
{record.artworkUrl ? <img src={record.artworkUrl} alt="" /> : <FileAudio size={16} />}
</div>
<div><strong>{record.title}</strong><span>{record.artist}</span></div>
</div>
<span>{record.album || "—"}</span>
<span className={styles.mono}>{record.isrc || "artist/title key"}</span>
<div className={styles.saved}>
<Clock3 size={13} />
<span>{new Date(record.addedAt).toLocaleString()}</span>
</div>
</div>
))}
{!visible.length ? (
<div className={styles.empty}>
<CopyCheck size={24} />
<strong>{records.length ? "No matching history" : "No local history yet"}</strong>
<span>Repaired files will appear here and become part of smart duplicate detection.</span>
</div>
) : null}
</section>
<MobileNav />
</main>
);
}
+571
View File
@@ -0,0 +1,571 @@
.page {
min-height: 100vh;
padding: 0 28px 48px;
background: #0b0c10;
color: var(--foreground);
font-family: var(--font-family-base);
}
.topbar,
.intro,
.notice,
.noticeError,
.workspace,
.queuePanel {
width: min(1220px, 100%);
margin-inline: auto;
}
.topbar {
min-height: 82px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 20px;
border-bottom: 1px solid #252932;
}
.backLink {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
color: #9aa4b1;
text-decoration: none;
font-size: .78rem;
}
.backLink:hover {
color: #fff;
}
.brandLogo {
display: block;
width: 250px;
max-height: 62px;
object-fit: contain;
}
.topStatus {
justify-self: end;
color: #6e7885;
font: .68rem/1 var(--font-family-monospace);
}
.intro {
display: flex;
justify-content: space-between;
align-items: end;
gap: 28px;
padding: 28px 0 22px;
}
.kicker,
.original span {
display: block;
color: #667688;
font: 500 .62rem/1.2 var(--font-family-monospace);
letter-spacing: .12em;
}
.intro h1 {
margin: 7px 0 7px;
font: 600 clamp(2rem, 4vw, 3rem)/1 var(--font-family-display);
letter-spacing: -.035em;
}
.intro p {
max-width: 680px;
margin: 0;
color: #88929e;
font-size: .86rem;
line-height: 1.6;
}
.summary {
display: flex;
gap: 28px;
flex: 0 0 auto;
}
.summary strong,
.summary span {
display: block;
}
.summary strong {
font: 600 1.1rem/1 var(--font-family-display);
}
.summary span {
margin-top: 4px;
color: #66717d;
font-size: .65rem;
}
.notice,
.noticeError {
margin-top: 6px;
padding: 9px 11px;
border: 1px solid #2f3a42;
background: #121a1d;
color: #9bcdd2;
font-size: .75rem;
}
.noticeError {
color: #f1a6bc;
background: #1c1218;
border-color: #49303a;
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 18px;
margin-top: 18px;
}
.sourcePanel,
.settingsPanel,
.queuePanel {
background: #111319;
border: 1px solid #2a2f39;
}
.sourcePanel,
.settingsPanel {
padding: 18px;
}
.panelHeading,
.settingsHeading {
display: flex;
align-items: center;
gap: 10px;
}
.panelHeading {
margin-bottom: 14px;
}
.panelHeading > svg,
.settingsHeading > svg {
color: var(--primary);
}
.panelHeading strong,
.panelHeading span {
display: block;
}
.panelHeading strong,
.settingsHeading strong {
font-size: .86rem;
}
.panelHeading span {
margin-top: 3px;
color: #737d89;
font-size: .7rem;
}
.actionRow {
display: flex;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
.settingsHeading {
margin-bottom: 16px;
}
.fieldLabel {
display: block;
margin-bottom: 6px;
color: #8b95a1;
font-size: .7rem;
}
.settingsPanel > p {
margin: 7px 0 16px;
color: #626d79;
font: .63rem/1.5 var(--font-family-monospace);
}
.settingLine {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
border-top: 1px solid #252932;
color: #86909b;
font-size: .7rem;
}
.settingLine svg {
color: #6ecfd9;
}
.queuePanel {
margin-top: 18px;
}
.queueHeader {
min-height: 66px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 16px;
background: #15181f;
border-bottom: 1px solid #2a2f39;
}
.queueHeader strong {
display: block;
margin-top: 4px;
font-size: .9rem;
}
.emptyState {
min-height: 230px;
display: grid;
place-items: center;
align-content: center;
gap: 7px;
color: #68727e;
text-align: center;
}
.emptyState strong {
color: #d8dde3;
font-size: .86rem;
}
.emptyState span {
font-size: .72rem;
}
.trackList {
width: 100%;
}
.trackCard {
display: grid;
grid-template-columns: 34px 42px minmax(220px, .85fr) minmax(420px, 1.7fr);
gap: 12px;
align-items: center;
min-height: 88px;
padding: 11px 14px;
background: #111319;
border-bottom: 1px solid #242831;
}
.trackCard:last-child {
border-bottom: 0;
}
.trackCard:hover {
background: #15181f;
}
.selectCell {
display: grid;
place-items: center;
}
.trackIndex {
color: #5e6874;
font: .68rem/1 var(--font-family-monospace);
}
.original {
min-width: 0;
}
.original strong,
.original small {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.original strong {
margin-top: 4px;
font-size: .8rem;
}
.original small {
margin-top: 3px;
color: #66717d;
font-size: .66rem;
}
.result {
min-width: 0;
display: grid;
grid-template-columns: 48px minmax(0, 1fr) 110px;
align-items: center;
gap: 10px;
}
.artwork,
.artworkFallback {
width: 48px;
height: 48px;
border: 1px solid #303641;
background: #1a1e26;
}
.artwork {
object-fit: cover;
}
.artworkFallback {
display: grid;
place-items: center;
color: #70808f;
}
.editFields {
min-width: 0;
}
.editFields > :global(input) {
height: 30px;
font-size: .72rem;
}
.twoFields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 5px;
}
.advancedFields {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
margin-top: 5px;
}
.lyricsField {
min-height: 58px;
margin-top: 5px;
font-size: .7rem;
}
.editFields small {
display: block;
margin-top: 5px;
color: #6b7580;
font-size: .62rem;
}
.matchState,
.savedState {
display: flex;
align-items: center;
gap: 5px;
justify-content: flex-end;
min-width: 0;
color: #7bdce5;
font-size: .67rem;
}
.savedState {
color: var(--success);
}
.savedState span,
.matchState span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.waiting,
.errorState {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
color: #7b8590;
font-size: .72rem;
}
.errorState {
color: #ef9ab3;
}
.manualState {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.manualState .waiting,
.manualState .errorState {
grid-column: auto;
}
@media (max-width: 980px) {
.workspace {
grid-template-columns: 1fr;
}
.trackCard {
grid-template-columns: 30px 34px 1fr;
}
.result {
grid-column: 3;
margin-top: 8px;
}
}
@media (max-width: 900px) {
.page {
padding: 0 10px 84px;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
min-height: 58px;
grid-template-columns: auto 1fr;
background: rgba(11,12,16,.97);
}
.brandLogo {
width: 132px;
justify-self: end;
}
.backLink {
font-size: 0;
}
.backLink svg {
width: 20px;
height: 20px;
}
.topStatus {
display: none;
}
.intro {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 18px 2px 14px;
}
.intro h1 {
font-size: 1.75rem;
}
.intro p {
font-size: .75rem;
line-height: 1.5;
}
.summary {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
border: 1px solid #282d35;
}
.summary > div {
padding: 10px 12px;
}
.summary > div + div {
border-left: 1px solid #282d35;
}
.workspace {
gap: 10px;
margin-top: 10px;
}
.sourcePanel,
.settingsPanel {
padding: 12px;
}
.settingsPanel {
background: #0f1116;
}
.queueHeader {
align-items: flex-start;
flex-direction: column;
gap: 9px;
padding-block: 12px;
}
.queueHeader button {
width: 100%;
}
.queuePanel {
margin-top: 10px;
border-left: 0;
border-right: 0;
}
.trackCard {
grid-template-columns: 28px 1fr;
gap: 8px;
padding: 10px 8px;
}
.trackIndex {
display: none;
}
.original,
.result {
grid-column: 2;
}
.result {
grid-template-columns: 42px minmax(0, 1fr);
}
.matchState,
.savedState {
grid-column: 2;
justify-content: flex-start;
}
.twoFields {
grid-template-columns: 1fr;
}
.advancedFields {
grid-template-columns: 1fr 1fr;
}
.artwork,
.artworkFallback {
width: 40px;
height: 40px;
}
.result {
grid-template-columns: 40px minmax(0, 1fr);
gap: 8px;
}
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+523
View File
@@ -0,0 +1,523 @@
import { useEffect, useMemo, useState } from "react";
import {
AudioLines,
CheckCircle2,
FileAudio,
FolderOpen,
Save,
Search,
Sparkles,
Tag,
WandSparkles,
} from "lucide-react";
import { FileDropzone } from "../components/FileDropzone";
import { Button } from "../components/Button";
import { Checkbox } from "../components/Checkbox";
import { Input } from "../components/Input";
import { Textarea } from "../components/Textarea";
import { postIdentify } from "../endpoints/identify_POST.schema";
import {
postMetadataEnrich,
type OutputType as MetadataEnrichment,
} from "../endpoints/metadata-enrich_POST.schema";
import { createRecognitionSnippet } from "../helpers/audioProcessing";
import {
downloadBlob,
formatFolderPath,
formatMp3Filename,
saveBlobToOrganizedPath,
type WritableDirectoryHandle,
writeMp3Tags,
} from "../helpers/mp3Tags";
import { loadSpotiRipSettings } from "../helpers/spotiripSettings";
import {
ensureDirectoryWritePermission,
loadOutputDirectory,
saveOutputDirectory,
} from "../helpers/directoryStore";
import { addLibraryHistory } from "../helpers/libraryHistory";
import {
findLibraryDuplicate,
rememberLibraryTrack,
} from "../helpers/libraryIndex";
import styles from "./identify.module.css";
import { MobileNav } from "../components/MobileNav";
type LocalTrack = {
id: string;
file: File;
status: "ready" | "identifying" | "identified" | "saved";
selected: boolean;
error?: string;
savedFilename?: string;
proposedTitle?: string;
proposedArtist?: string;
proposedAlbum?: string;
proposedAlbumArtist?: string;
proposedReleaseDate?: string;
proposedTrackNumber?: number;
proposedDiscNumber?: number;
proposedGenre?: string;
proposedComposer?: string;
proposedIsrc?: string;
proposedLyrics?: string;
metadataSources?: string[];
duplicatePath?: string;
artworkUrl?: string;
};
function cleanGuess(filename: string) {
return filename
.replace(/\.[^.]+$/, "")
.replace(/[_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export default function IdentifyPage() {
const [settings] = useState(() => loadSpotiRipSettings());
const [tracks, setTracks] = useState<LocalTrack[]>([]);
const [identifying, setIdentifying] = useState(false);
const [applying, setApplying] = useState(false);
const [outputDirectory, setOutputDirectory] = useState<WritableDirectoryHandle>();
const [filenameTemplate, setFilenameTemplate] = useState(settings.filenameTemplate);
const [message, setMessage] = useState<string>();
const [error, setError] = useState<string>();
const totalSize = useMemo(
() => tracks.reduce((sum, track) => sum + track.file.size, 0),
[tracks],
);
const matched = useMemo(() => tracks.filter((track) => track.status === "identified" || track.status === "saved").length, [tracks]);
const selectedCount = useMemo(() => tracks.filter((track) => track.selected && track.status === "identified").length, [tracks]);
useEffect(() => {
loadOutputDirectory().then(setOutputDirectory).catch(() => undefined);
}, []);
function addFiles(files: File[]) {
setTracks((current) => [
...current,
...files
.filter((file) => !current.some((item) => item.id === `${file.name}-${file.size}-${file.lastModified}`))
.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}`,
file,
status: "ready" as const,
selected: false,
})),
]);
setMessage(undefined);
setError(undefined);
}
async function chooseOutputFolder() {
const picker = (window as typeof window & {
showDirectoryPicker?: () => Promise<WritableDirectoryHandle>;
}).showDirectoryPicker;
if (!picker) {
setError("This browser does not support direct folder writing. SpotiRip will use normal file downloads instead.");
return;
}
try {
const handle = await picker();
setOutputDirectory(handle);
await saveOutputDirectory(handle);
setMessage(`Fixed files will be saved to “${handle.name}”.`);
setError(undefined);
} catch {
}
}
async function identifyTracks() {
const pending = tracks.filter((track) => track.status === "ready" || Boolean(track.error));
if (!pending.length || identifying) return;
setIdentifying(true);
setError(undefined);
setMessage(undefined);
for (const track of pending) {
setTracks((current) =>
current.map((item) => item.id === track.id ? { ...item, status: "identifying", error: undefined } : item),
);
try {
const snippet = await createRecognitionSnippet(track.file);
const result = await postIdentify(snippet);
const title = result.title || cleanGuess(track.file.name);
const artist = result.artist || "Unknown artist";
const album = result.album || "Unknown album";
const enrichment: MetadataEnrichment = result.matched
? await postMetadataEnrich({
title,
artist,
album,
isrc: result.isrc,
}).catch(() => ({ sources: [] } as MetadataEnrichment))
: { sources: [] };
const resolvedIsrc = enrichment.isrc || result.isrc;
const duplicate = result.matched && settings.smartDedup
? await findLibraryDuplicate({
isrc: resolvedIsrc,
title,
artist,
}).catch(() => undefined)
: undefined;
setTracks((current) =>
current.map((item) =>
item.id !== track.id
? item
: result.matched
? {
...item,
status: "identified",
selected: settings.autoSelectMatches && !duplicate,
error: undefined,
proposedTitle: title,
proposedArtist: artist,
proposedAlbum: album,
proposedAlbumArtist: enrichment.albumArtist,
proposedReleaseDate: enrichment.releaseDate || result.releaseDate,
proposedTrackNumber: result.trackNumber,
proposedDiscNumber: result.discNumber,
proposedGenre: enrichment.genre,
proposedComposer: enrichment.composer,
proposedIsrc: resolvedIsrc,
proposedLyrics: enrichment.lyrics?.plain || enrichment.lyrics?.synced,
metadataSources: enrichment.sources,
duplicatePath: duplicate?.savedPath,
artworkUrl: result.artworkUrl || enrichment.artworkUrl,
}
: {
...item,
status: "ready",
selected: false,
error: "No confident audio match found.",
},
),
);
} catch (reason) {
const detail = reason instanceof Error ? reason.message : "Identification failed.";
setTracks((current) =>
current.map((item) => item.id === track.id ? { ...item, status: "ready", selected: false, error: detail } : item),
);
}
}
setIdentifying(false);
setMessage("Identification pass complete. Review the proposed matches before saving.");
}
function updateTrack(id: string, changes: Partial<LocalTrack>) {
setTracks((current) => current.map((track) => track.id === id ? { ...track, ...changes } : track));
}
function editManually(id: string) {
setTracks((current) =>
current.map((track) =>
track.id === id
? {
...track,
status: "identified",
selected: true,
error: undefined,
proposedTitle: track.proposedTitle || cleanGuess(track.file.name),
proposedArtist: track.proposedArtist || "",
proposedAlbum: track.proposedAlbum || "",
}
: track,
),
);
}
async function applySelected() {
const selected = tracks.filter((track) => track.selected && track.status === "identified");
if (!selected.length || applying) return;
setApplying(true);
setError(undefined);
setMessage(undefined);
let completed = 0;
let activeDirectory = outputDirectory;
try {
if (activeDirectory && !(await ensureDirectoryWritePermission(activeDirectory))) {
activeDirectory = undefined;
setOutputDirectory(undefined);
setMessage("Folder permission was not granted; corrected files will download normally.");
}
for (const track of selected) {
const trackLabel = track.proposedTrackNumber
? settings.multiDiscMode === "offset" && track.proposedDiscNumber
? String(track.proposedDiscNumber * 100 + track.proposedTrackNumber)
: settings.multiDiscMode === "disc-prefix" && track.proposedDiscNumber
? `${track.proposedDiscNumber}-${String(track.proposedTrackNumber).padStart(2, "0")}`
: String(track.proposedTrackNumber).padStart(2, "0")
: undefined;
const metadata = {
title: track.proposedTitle || cleanGuess(track.file.name),
artist: track.proposedArtist || "Unknown Artist",
album: track.proposedAlbum,
albumArtist: track.proposedAlbumArtist,
year: track.proposedReleaseDate?.slice(0, 4),
trackNumber: track.proposedTrackNumber,
trackLabel,
discNumber: track.proposedDiscNumber,
genre: track.proposedGenre,
composer: track.proposedComposer,
isrc: track.proposedIsrc,
lyrics: track.proposedLyrics,
artworkUrl: settings.embedArtwork ? track.artworkUrl : undefined,
};
const blob = await writeMp3Tags(track.file, metadata, settings.mp3Bitrate);
const filename = formatMp3Filename(filenameTemplate, metadata);
const folders = formatFolderPath(settings.folderTemplate, metadata);
const savedFilename = activeDirectory
? await saveBlobToOrganizedPath(activeDirectory, folders, filename, blob)
: (downloadBlob(filename, blob), filename);
await rememberLibraryTrack({
isrc: metadata.isrc,
title: metadata.title,
artist: metadata.artist,
album: metadata.album,
albumArtist: metadata.albumArtist,
savedPath: savedFilename,
artworkUrl: metadata.artworkUrl,
source: "identifier",
}).catch(() => undefined);
completed += 1;
updateTrack(track.id, {
status: "saved",
selected: false,
savedFilename,
error: undefined,
});
}
setMessage(
activeDirectory
? `Saved ${completed} repaired MP3 file${completed === 1 ? "" : "s"} to “${activeDirectory.name}”.`
: `Prepared ${completed} repaired MP3 download${completed === 1 ? "" : "s"}.`,
);
if (completed > 0) {
addLibraryHistory({
kind: "identify",
title: `Repaired ${completed} audio file${completed === 1 ? "" : "s"}`,
trackCount: completed,
});
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Unable to save corrected MP3 files.");
} finally {
setApplying(false);
}
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<div className={styles.topStatus}>{matched}/{tracks.length} matched</div>
</header>
<section className={styles.intro}>
<div>
<span className={styles.kicker}>LOCAL AUDIO TOOLS</span>
<h1>Audio Identifier</h1>
<p>Recognize unknown audio files, review the match, then create corrected MP3 copies with clean ID3 tags and filenames.</p>
</div>
<div className={styles.summary}>
<div><strong>{tracks.length}</strong><span>Files</span></div>
<div><strong>{(totalSize / 1024 / 1024).toFixed(1)} MB</strong><span>Audio</span></div>
<div><strong>{selectedCount}</strong><span>Selected</span></div>
</div>
</section>
{(message || error) && (
<div className={error ? styles.noticeError : styles.notice}>{error || message}</div>
)}
<section className={styles.workspace}>
<div className={styles.sourcePanel}>
<div className={styles.panelHeading}>
<AudioLines size={20} />
<div>
<strong>Add unknown tracks</strong>
<span>Only a short audio fingerprint sample is sent for recognition.</span>
</div>
</div>
<FileDropzone
accept=".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg"
maxFiles={100}
maxSize={80 * 1024 * 1024}
onFilesSelected={addFiles}
icon={<FileAudio size={36} />}
title="Drop audio files here"
subtitle="MP3, WAV, M4A, AAC or OGG · up to 100 files"
/>
<div className={styles.actionRow}>
<Button disabled={!tracks.length || identifying} onClick={identifyTracks}>
<Search size={16} /> {identifying ? "Listening…" : "Identify tracks"}
</Button>
<Button variant="outline" onClick={chooseOutputFolder}>
<FolderOpen size={16} /> {outputDirectory?.name || "Choose output folder"}
</Button>
</div>
</div>
<aside className={styles.settingsPanel}>
<div className={styles.settingsHeading}><Tag size={18} /><strong>Save settings</strong></div>
<label className={styles.fieldLabel} htmlFor="filename-template">Filename template</label>
<Input
id="filename-template"
value={filenameTemplate}
onChange={(event) => setFilenameTemplate(event.target.value)}
placeholder="{artist} - {title}"
/>
<p>Tokens: {"{artist}"} {"{title}"} {"{album}"} {"{year}"} {"{track}"} {"{disc}"} {"{genre}"} {"{composer}"} {"{isrc}"}</p>
<div className={styles.settingLine}>
<CheckCircle2 size={15} />
<span>Original files are preserved</span>
</div>
<div className={styles.settingLine}>
<Save size={15} />
<span>{outputDirectory ? `Save to ${outputDirectory.name}` : "Download fallback enabled"}</span>
</div>
<div className={styles.settingLine}>
<FileAudio size={15} />
<span>Non-MP3 sources convert at {settings.mp3Bitrate} kbps</span>
</div>
</aside>
</section>
<section className={styles.queuePanel}>
<div className={styles.queueHeader}>
<div>
<span className={styles.kicker}>REVIEW QUEUE</span>
<strong>{tracks.length ? "Matches and metadata" : "No files loaded"}</strong>
</div>
<Button disabled={!selectedCount || applying} onClick={applySelected}>
<Save size={16} /> {applying ? "Saving…" : selectedCount ? `Apply ${selectedCount} selected` : "Apply selected"}
</Button>
</div>
{!tracks.length ? (
<div className={styles.emptyState}>
<Sparkles size={26} />
<strong>No audio files queued</strong>
<span>Add MP3 files above to start identifying them.</span>
</div>
) : (
<div className={styles.trackList}>
{tracks.map((track, index) => (
<article className={styles.trackCard} key={track.id}>
<div className={styles.selectCell}>
<Checkbox
aria-label={`Select ${track.file.name}`}
checked={track.selected}
disabled={track.status !== "identified"}
onChange={(event) => updateTrack(track.id, { selected: event.target.checked })}
/>
</div>
<div className={styles.trackIndex}>{String(index + 1).padStart(2, "0")}</div>
<div className={styles.original}>
<span>CURRENT FILE</span>
<strong>{track.file.name}</strong>
<small>{(track.file.size / 1024 / 1024).toFixed(1)} MB</small>
</div>
<div className={styles.result}>
{track.status === "identified" || track.status === "saved" ? (
<>
{track.artworkUrl ? <img className={styles.artwork} src={track.artworkUrl} alt="" /> : <div className={styles.artworkFallback}><FileAudio size={18} /></div>}
<div className={styles.editFields}>
<Input
value={track.proposedTitle || ""}
onChange={(event) => updateTrack(track.id, { proposedTitle: event.target.value })}
aria-label="Track title"
/>
<div className={styles.twoFields}>
<Input
value={track.proposedArtist || ""}
onChange={(event) => updateTrack(track.id, { proposedArtist: event.target.value })}
aria-label="Artist"
/>
<Input
value={track.proposedAlbum || ""}
onChange={(event) => updateTrack(track.id, { proposedAlbum: event.target.value })}
aria-label="Album"
/>
</div>
<div className={styles.advancedFields}>
<Input
value={track.proposedGenre || ""}
onChange={(event) => updateTrack(track.id, { proposedGenre: event.target.value })}
placeholder="Genre"
aria-label="Genre"
/>
<Input
value={track.proposedComposer || ""}
onChange={(event) => updateTrack(track.id, { proposedComposer: event.target.value })}
placeholder="Composer"
aria-label="Composer"
/>
<Input
value={track.proposedIsrc || ""}
onChange={(event) => updateTrack(track.id, { proposedIsrc: event.target.value })}
placeholder="ISRC"
aria-label="ISRC"
/>
<Input
type="number"
min={1}
value={track.proposedDiscNumber || ""}
onChange={(event) => updateTrack(track.id, { proposedDiscNumber: event.target.value ? Number(event.target.value) : undefined })}
placeholder="Disc"
aria-label="Disc number"
/>
</div>
<Textarea
className={styles.lyricsField}
value={track.proposedLyrics || ""}
onChange={(event) => updateTrack(track.id, { proposedLyrics: event.target.value })}
placeholder="Lyrics (optional)"
aria-label="Lyrics"
rows={2}
/>
<small>
{track.proposedReleaseDate?.slice(0, 4) || "Year unknown"}
{track.proposedTrackNumber ? ` · Track ${track.proposedTrackNumber}` : ""}
{track.metadataSources?.length ? ` · ${track.metadataSources.join(" + ")}` : ""}
{track.duplicatePath ? ` · Duplicate: ${track.duplicatePath}` : ""}
</small>
</div>
<div className={track.status === "saved" ? styles.savedState : styles.matchState}>
<CheckCircle2 size={15} />
<span>{track.status === "saved" ? track.savedFilename || "Saved" : "Match ready"}</span>
</div>
</>
) : track.status === "identifying" ? (
<div className={styles.waiting}><WandSparkles size={17} /><span>Listening…</span></div>
) : track.error ? (
<div className={styles.manualState}>
<span className={styles.errorState}>{track.error}</span>
<Button variant="outline" size="sm" onClick={() => editManually(track.id)}>Edit manually</Button>
</div>
) : (
<div className={styles.manualState}>
<div className={styles.waiting}><WandSparkles size={17} /><span>Ready to analyze</span></div>
<Button variant="ghost" size="sm" onClick={() => editManually(track.id)}>Manual metadata</Button>
</div>
)}
</div>
</article>
))}
</div>
)}
</section>
<MobileNav />
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
.page{min-height:100vh;padding:0 28px 50px;color:var(--foreground);background:var(--background);font-family:var(--font-family-base)}
.topbar,.heading,.notice,.noticeError,.stats,.panel{width:min(1180px,100%);margin-inline:auto}
.topbar{min-height:82px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--border)}
.topbar img{width:230px;max-height:60px;object-fit:contain}
.topbar>span{justify-self:end;color:var(--muted-foreground);font:.68rem/1 var(--font-family-monospace)}
.backLink{display:inline-flex;align-items:center;gap:7px;color:var(--muted-foreground);text-decoration:none;font-size:.78rem}
.heading{display:flex;align-items:end;justify-content:space-between;gap:20px;padding:28px 0 20px}
.heading>div:first-child>span,.libraryName span{color:#66727e;font:500 .62rem/1 var(--font-family-monospace);letter-spacing:.12em}
.heading h1{margin:7px 0;font:600 2.1rem/1 var(--font-family-display)}
.heading p{max-width:730px;margin:0;color:var(--muted-foreground);font-size:.8rem;line-height:1.55}
.actions{display:flex;gap:8px;flex-wrap:wrap}
.notice,.noticeError{margin-bottom:12px;padding:9px 11px;border:1px solid #2f3a42;background:#121a1d;color:#9bcdd2;font-size:.72rem}
.noticeError{color:#f1a6bc;background:#1c1218;border-color:#49303a}
.stats{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--border);background:var(--card);margin-bottom:14px}
.stats div{padding:13px 14px;border-right:1px solid var(--border)}
.stats div:last-child{border-right:0}
.stats strong,.stats span{display:block}
.stats strong{font:600 1rem/1 var(--font-family-display)}
.stats span{margin-top:4px;color:#6d7884;font-size:.64rem}
.panel{border:1px solid var(--border);background:var(--card)}
.toolbar{min-height:64px;display:grid;grid-template-columns:1fr minmax(220px,340px) auto;align-items:center;gap:12px;padding:0 14px;background:#15181f;border-bottom:1px solid var(--border)}
.libraryName{display:flex;align-items:center;gap:9px;min-width:0}
.libraryName>svg{color:var(--primary)}
.libraryName strong{display:block;margin-top:4px;font-size:.78rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.search{display:flex;align-items:center;gap:7px;color:#6e7985}
.search input{height:32px}
.progress{color:#72cbd4;font:.66rem/1 var(--font-family-monospace)}
.headerRow,.row{display:grid;grid-template-columns:minmax(220px,1.25fr) minmax(280px,1.5fr) minmax(130px,.65fr) minmax(160px,.8fr);gap:14px;align-items:center}
.headerRow{min-height:34px;padding:0 14px;color:#64707c;background:#0f1116;border-bottom:1px solid var(--border);font:500 .62rem/1 var(--font-family-monospace);text-transform:uppercase;letter-spacing:.08em}
.row{min-height:74px;padding:8px 14px;border-bottom:1px solid #242831}
.row:last-child{border-bottom:0}
.row:hover{background:#15181f}
.file,.meta,.audio,.issues{min-width:0}
.file strong,.file span,.meta strong,.meta span,.meta small,.audio strong,.audio span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file strong,.meta strong,.audio strong{font-size:.76rem}
.file span,.meta span,.audio span{margin-top:3px;color:#697480;font-size:.65rem}
.meta small{margin-top:4px;color:#58636f;font:.6rem/1.3 var(--font-family-monospace)}
.issues{display:flex;align-items:center;gap:7px;flex-wrap:wrap;color:#c9a56c;font-size:.64rem}
.issues span{display:inline-flex;align-items:center;gap:4px}
.duplicate{color:#e6a26f}
.clean{color:#65c9a2}
.empty{padding:38px 16px;color:#6c7783;text-align:center;font-size:.74rem}
@media(max-width:900px){
.page{padding:0 10px 84px}
.topbar{position:sticky;top:0;z-index:20;min-height:58px;grid-template-columns:auto 1fr;background:rgba(11,12,16,.97)}
.topbar img{width:132px;justify-self:end}
.topbar>span{display:none}
.backLink{font-size:0}.backLink svg{width:20px;height:20px}
.heading{align-items:flex-start;flex-direction:column;gap:12px;padding:18px 2px 14px}
.heading h1{font-size:1.75rem}.heading p{font-size:.74rem;line-height:1.5}
.actions{width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px}.actions button{width:100%}
.stats{grid-template-columns:repeat(2,1fr)}
.stats div:nth-child(2){border-right:0}
.stats div:nth-child(-n+2){border-bottom:1px solid var(--border)}
.stats div{padding:10px 11px}
.panel{border-left:0;border-right:0}
.toolbar{grid-template-columns:1fr;padding:10px}
.search input{height:40px}
.headerRow{display:none}
.row{grid-template-columns:1fr auto;gap:6px;padding:10px}
.file{grid-column:1}
.meta{grid-column:1}
.audio{grid-column:2;grid-row:1/3;text-align:right;align-self:center}
.issues{grid-column:1/-1;padding-top:4px}
.meta small{display:none}
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+214
View File
@@ -0,0 +1,214 @@
import { useMemo, useState } from "react";
import {
AlertTriangle,
CheckCircle2,
Copy,
FolderOpen,
Library,
Search,
} from "lucide-react";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import {
scanAudioLibrary,
type ScannedLibraryTrack,
} from "../helpers/libraryScanner";
import { copyOrganizedTrack, suggestedLibraryPath } from "../helpers/libraryOrganizer";
import { loadSpotiRipSettings } from "../helpers/spotiripSettings";
import { rememberLibraryTrack } from "../helpers/libraryIndex";
import styles from "./library.module.css";
import { MobileNav } from "../components/MobileNav";
export default function LibraryPage() {
const [settings] = useState(() => loadSpotiRipSettings());
const [rootName, setRootName] = useState<string>();
const [tracks, setTracks] = useState<ScannedLibraryTrack[]>([]);
const [filter, setFilter] = useState("");
const [scanning, setScanning] = useState(false);
const [progress, setProgress] = useState({ completed: 0, total: 0 });
const [organizing, setOrganizing] = useState(false);
const [message, setMessage] = useState<string>();
const [error, setError] = useState<string>();
const visible = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return tracks;
return tracks.filter((track) =>
[track.title, track.artist, track.album, track.relativePath]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(q)),
);
}, [tracks, filter]);
const stats = useMemo(() => ({
missing: tracks.filter((track) => track.missing.length).length,
duplicates: tracks.filter((track) => track.duplicateOf).length,
artwork: tracks.filter((track) => track.artworkPresent).length,
}), [tracks]);
async function scanFolder() {
const picker = (window as typeof window & {
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
}).showDirectoryPicker;
if (!picker) {
setError("Folder scanning requires a browser with the File System Access API.");
return;
}
try {
const directory = await picker();
setRootName(directory.name);
setTracks([]);
setScanning(true);
setError(undefined);
setMessage(undefined);
setProgress({ completed: 0, total: 0 });
const result = await scanAudioLibrary(directory, (completed, total) =>
setProgress({ completed, total }),
);
setTracks(result);
setMessage(
"Scanned " + result.length + " audio file" + (result.length === 1 ? "" : "s") +
" in “" + directory.name + "”.",
);
} catch (reason) {
if (reason instanceof DOMException && reason.name === "AbortError") return;
setError(reason instanceof Error ? reason.message : "Library scan failed.");
} finally {
setScanning(false);
}
}
async function organizeCopies() {
if (!tracks.length || organizing) return;
const picker = (window as typeof window & {
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
}).showDirectoryPicker;
if (!picker) {
setError("Folder organization requires a browser with the File System Access API.");
return;
}
try {
const destination = await picker();
setOrganizing(true);
setError(undefined);
let completed = 0;
const source = tracks.filter((track) => !track.duplicateOf);
for (const track of source) {
const savedPath = await copyOrganizedTrack(destination, track, settings);
await rememberLibraryTrack({
isrc: track.isrc,
title: track.title,
artist: track.artist || "Unknown Artist",
album: track.album,
albumArtist: track.albumArtist,
savedPath,
source: "library-organizer",
}).catch(() => undefined);
completed += 1;
setProgress({ completed, total: source.length });
}
setMessage(
"Copied " + completed + " organized file" + (completed === 1 ? "" : "s") +
" into “" + destination.name + "”. Originals were not changed.",
);
} catch (reason) {
if (reason instanceof DOMException && reason.name === "AbortError") return;
setError(reason instanceof Error ? reason.message : "Library organization failed.");
} finally {
setOrganizing(false);
}
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<span>Local Library</span>
</header>
<section className={styles.heading}>
<div>
<span>LOCAL LIBRARY</span>
<h1>Library Scanner</h1>
<p>Inventory your music folder, inspect existing tags, find duplicates and missing metadata, then organize clean copies without modifying the source library.</p>
</div>
<div className={styles.actions}>
<Button variant="outline" disabled={scanning} onClick={scanFolder}>
<FolderOpen size={16} /> {scanning ? "Scanning…" : "Scan folder"}
</Button>
<Button disabled={!tracks.length || organizing} onClick={organizeCopies}>
<Copy size={16} /> {organizing ? "Organizing…" : "Organize copies"}
</Button>
</div>
</section>
{(message || error) ? <div className={error ? styles.noticeError : styles.notice}>{error || message}</div> : null}
<section className={styles.stats}>
<div><strong>{tracks.length}</strong><span>Audio files</span></div>
<div><strong>{stats.missing}</strong><span>Need metadata</span></div>
<div><strong>{stats.duplicates}</strong><span>Duplicates</span></div>
<div><strong>{stats.artwork}</strong><span>With artwork</span></div>
</section>
<section className={styles.panel}>
<div className={styles.toolbar}>
<div className={styles.libraryName}>
<Library size={17} />
<div><span>SCANNED ROOT</span><strong>{rootName || "No folder selected"}</strong></div>
</div>
<div className={styles.search}>
<Search size={15} />
<Input value={filter} onChange={(event) => setFilter(event.target.value)} placeholder="Filter library" />
</div>
{(scanning || organizing) && progress.total ? (
<span className={styles.progress}>{progress.completed}/{progress.total}</span>
) : null}
</div>
<div className={styles.headerRow}>
<span>File</span><span>Metadata</span><span>Audio</span><span>Issues</span>
</div>
{visible.map((track) => {
const target = suggestedLibraryPath(track, settings);
return (
<article className={styles.row} key={track.id}>
<div className={styles.file}>
<strong>{track.relativePath}</strong>
<span>{(track.file.size / 1024 / 1024).toFixed(1)} MB</span>
</div>
<div className={styles.meta}>
<strong>{track.title}</strong>
<span>{track.artist || "Unknown artist"} · {track.album || "Unknown album"}</span>
<small>{target.folders.join(" / ")} / {target.filename}</small>
</div>
<div className={styles.audio}>
<strong>{track.codec?.toUpperCase() || "—"}</strong>
<span>
{track.bitrateKbps ? track.bitrateKbps + " kbps" : "bitrate —"} ·{" "}
{track.sampleRate ? Math.round(track.sampleRate / 100) / 10 + " kHz" : "rate —"}
</span>
</div>
<div className={styles.issues}>
{track.duplicateOf ? (
<span className={styles.duplicate}><AlertTriangle size={13} /> duplicate</span>
) : null}
{track.missing.length ? (
<span>{track.missing.join(", ")}</span>
) : (
<span className={styles.clean}><CheckCircle2 size={13} /> clean</span>
)}
</div>
</article>
);
})}
{!tracks.length ? <div className={styles.empty}>Choose a music folder to build a local library inventory.</div> : null}
{tracks.length > 0 && visible.length === 0 ? (
<div className={styles.empty}>No library items match “{filter}”.</div>
) : null}
</section>
<MobileNav />
</main>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+5
View File
@@ -0,0 +1,5 @@
import { Navigate } from "react-router-dom";
export default function QualityPage() {
return <Navigate to="/analyze" replace />;
}
+189
View File
@@ -0,0 +1,189 @@
.page {
min-height: 100vh;
padding: 0 28px 50px;
color: var(--foreground);
background: var(--background);
font-family: var(--font-family-base);
}
.topbar,
.heading,
.panel {
width: min(900px, 100%);
margin-inline: auto;
}
.topbar {
min-height: 82px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 20px;
border-bottom: 1px solid var(--border);
}
.topbar img {
width: 240px;
max-height: 60px;
object-fit: contain;
}
.topbar > span {
justify-self: end;
color: var(--muted-foreground);
font: .68rem/1 var(--font-family-monospace);
}
.backLink {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
color: var(--muted-foreground);
text-decoration: none;
font-size: .78rem;
}
.heading {
display: flex;
align-items: center;
gap: 12px;
padding: 28px 0 20px;
}
.heading > svg {
color: var(--primary);
}
.heading h1 {
margin: 0;
font: 600 1.8rem/1 var(--font-family-display);
}
.heading p {
margin: 5px 0 0;
color: var(--muted-foreground);
font-size: .78rem;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
}
.row,
.checkRow {
display: grid;
align-items: start;
gap: 12px;
padding: 18px;
border-bottom: 1px solid var(--border);
}
.row {
grid-template-columns: 22px 1fr;
}
.row > svg,
.checkRow > svg {
margin-top: 3px;
color: #7d8996;
}
.rowContent label {
display: block;
margin-bottom: 7px;
font-size: .76rem;
}
.rowContent small {
display: block;
margin-top: 7px;
color: #68727d;
font: .63rem/1.4 var(--font-family-monospace);
}
.checkRow {
grid-template-columns: 22px 20px 1fr;
cursor: pointer;
}
.checkRow strong,
.checkRow span {
display: block;
}
.checkRow strong {
font-size: .78rem;
}
.checkRow span {
margin-top: 4px;
color: var(--muted-foreground);
font-size: .7rem;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
}
.footer span {
color: #717c88;
font-size: .7rem;
}
@media (max-width: 900px) {
.page {
padding: 0 10px 84px;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
min-height: 58px;
grid-template-columns: auto 1fr;
background: rgba(11,12,16,.97);
}
.topbar img {
width: 132px;
justify-self: end;
}
.topbar > span {
display: none;
}
.backLink { font-size: 0; }
.backLink svg { width: 20px; height: 20px; }
.heading { padding: 18px 2px 14px; }
.heading h1 { font-size: 1.55rem; }
.heading p { font-size: .72rem; }
.panel { border-left: 0; border-right: 0; }
.row,
.checkRow { padding: 14px 10px; gap: 9px; }
.row { grid-template-columns: 18px 1fr; }
.checkRow { grid-template-columns: 22px 18px 1fr; }
.rowContent small { font-size: .58rem; }
.footer {
align-items: stretch;
flex-direction: column;
padding: 12px 10px;
}
.footer button { width: 100%; }
}
.folderRow {
display: flex;
gap: 8px;
}
.folderRow > :first-child {
flex: 1;
min-width: 0;
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useState } from "react";
import {
CopyCheck,
Download,
FolderTree,
Image,
Music2,
Save,
Settings2,
Tags,
} from "lucide-react";
import { Button } from "../components/Button";
import { Checkbox } from "../components/Checkbox";
import { Input } from "../components/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/Select";
import {
loadSpotiRipSettings,
saveSpotiRipSettings,
type SpotiRipSettings,
} from "../helpers/spotiripSettings";
import { getDesktopBridge } from "../helpers/desktopBridge";
import styles from "./settings.module.css";
import { MobileNav } from "../components/MobileNav";
export default function SettingsPage() {
const [settings, setSettings] = useState<SpotiRipSettings>(() => loadSpotiRipSettings());
const [saved, setSaved] = useState(false);
const desktop = getDesktopBridge();
const [downloadFolder, setDownloadFolder] = useState<string>();
useEffect(() => {
desktop?.getDownloadFolder().then(setDownloadFolder).catch(() => undefined);
}, []);
async function chooseDownloadFolder() {
const chosen = await desktop?.chooseDownloadFolder();
if (chosen) setDownloadFolder(chosen);
}
function update(changes: Partial<SpotiRipSettings>) {
setSaved(false);
setSettings((current) => ({ ...current, ...changes }));
}
function save() {
saveSpotiRipSettings(settings);
setSaved(true);
}
return (
<main className={styles.page}>
<header className={styles.topbar}>
<span>Preferences</span>
</header>
<section className={styles.heading}>
<Settings2 size={22} />
<div>
<h1>Settings</h1>
<p>Defaults for local file repair and metadata cleanup.</p>
</div>
</section>
<section className={styles.panel}>
{desktop ? (
<div className={styles.row}>
<Download size={18} />
<div className={styles.rowContent}>
<label>Download folder</label>
<div className={styles.folderRow}>
<Input value={downloadFolder || ""} readOnly aria-label="Download folder" />
<Button variant="outline" onClick={chooseDownloadFolder}>Change…</Button>
</div>
<small>Songs are saved here; each playlist gets its own subfolder. Changes apply immediately.</small>
</div>
</div>
) : null}
<div className={styles.row}>
<Tags size={18} />
<div className={styles.rowContent}>
<label htmlFor="filename-template">Filename template</label>
<Input
id="filename-template"
value={settings.filenameTemplate}
onChange={(event) => update({ filenameTemplate: event.target.value })}
/>
<small>Available tokens: {"{artist}"} {"{title}"} {"{album}"} {"{year}"} {"{track}"}</small>
</div>
</div>
<div className={styles.row}>
<FolderTree size={18} />
<div className={styles.rowContent}>
<label htmlFor="folder-template">Folder structure</label>
<Input
id="folder-template"
value={settings.folderTemplate}
onChange={(event) => update({ folderTemplate: event.target.value })}
/>
<small>
Tokens: {"{album_artist}"} {"{artist}"} {"{album}"} {"{year}"} {"{genre}"} {"{disc}"}
</small>
</div>
</div>
<div className={styles.row}>
<Music2 size={18} />
<div className={styles.rowContent}>
<label>Multi-disc numbering</label>
<Select
value={settings.multiDiscMode}
onValueChange={(value) =>
update({ multiDiscMode: value as SpotiRipSettings["multiDiscMode"] })
}
>
<SelectTrigger aria-label="Multi-disc numbering style"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="disc-prefix">Disc prefix · 2-05</SelectItem>
<SelectItem value="offset">Disc offset · 205</SelectItem>
<SelectItem value="track-only">Track only · 05</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className={styles.row}>
<Music2 size={18} />
<div className={styles.rowContent}>
<label>MP3 conversion bitrate</label>
<Select
value={String(settings.mp3Bitrate)}
onValueChange={(value) =>
update({ mp3Bitrate: Number(value) as SpotiRipSettings["mp3Bitrate"] })
}
>
<SelectTrigger aria-label="Default MP3 bitrate"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="128">128 kbps</SelectItem>
<SelectItem value="192">192 kbps</SelectItem>
<SelectItem value="256">256 kbps</SelectItem>
<SelectItem value="320">320 kbps</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<label className={styles.checkRow}>
<Checkbox
checked={settings.embedArtwork}
onChange={(event) => update({ embedArtwork: event.target.checked })}
/>
<Image size={17} />
<div>
<strong>Embed cover artwork</strong>
<span>Add recognized album artwork to repaired MP3 tags when available.</span>
</div>
</label>
<label className={styles.checkRow}>
<Checkbox
checked={settings.smartDedup}
onChange={(event) => update({ smartDedup: event.target.checked })}
/>
<CopyCheck size={17} />
<div>
<strong>Smart deduplication</strong>
<span>Warn when an ISRC or normalized artist/title already exists in the local SpotiRip index.</span>
</div>
</label>
<label className={styles.checkRow}>
<Checkbox
checked={settings.autoSelectMatches}
onChange={(event) => update({ autoSelectMatches: event.target.checked })}
/>
<Tags size={17} />
<div>
<strong>Auto-select confident matches</strong>
<span>Newly recognized tracks are selected for batch apply by default.</span>
</div>
</label>
<div className={styles.footer}>
<span>{saved ? "Settings saved on this device." : "Original source files are always preserved."}</span>
<Button onClick={save}><Save size={16} /> Save settings</Button>
</div>
</section>
<MobileNav />
</main>
);
}
+77
View File
@@ -0,0 +1,77 @@
.page{min-height:100vh;padding:0 28px 132px;color:var(--foreground);background:var(--background);font-family:var(--font-family-base)}
.topbar,.heading,.noticeError,.panel{width:min(1180px,100%);margin-inline:auto}
.topbar{min-height:82px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--border)}
.topbar img{width:230px;max-height:60px;object-fit:contain}
.topbar>span{justify-self:end;color:var(--muted-foreground);font:.68rem/1 var(--font-family-monospace)}
.backLink{display:inline-flex;align-items:center;gap:7px;color:var(--muted-foreground);text-decoration:none;font-size:.78rem}
.heading{display:flex;align-items:end;justify-content:space-between;gap:20px;padding:28px 0 20px}
.heading>div:first-child>span{color:#66727e;font:500 .62rem/1 var(--font-family-monospace);letter-spacing:.12em}
.heading h1{margin:7px 0;font:600 2.1rem/1 var(--font-family-display)}
.heading p{max-width:720px;margin:0;color:var(--muted-foreground);font-size:.8rem;line-height:1.55}
.account{display:flex;align-items:center;gap:9px;padding:8px 10px;border:1px solid var(--border);background:#15181f}
.account img{width:34px;height:34px;object-fit:cover;border-radius:50%}
.account strong,.account span{display:block}
.account strong{font-size:.76rem}
.account span{margin-top:3px;color:#6e7985;font-size:.64rem}
.noticeError{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;padding:9px 11px;color:#f1a6bc;background:#1c1218;border:1px solid #49303a;font-size:.72rem}
.panel{border:1px solid var(--border);background:var(--card)}
.toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:0 12px;background:#15181f;border-bottom:1px solid var(--border)}
.toolbar :global([role="tablist"]){margin:0}
.count{color:#68737f;font:.65rem/1 var(--font-family-monospace)}
.trackList{width:100%}
.trackHeader,.trackRow{display:grid;grid-template-columns:42px minmax(240px,1.55fr) minmax(170px,1fr) 100px 108px;align-items:center;gap:12px}
.trackHeader{min-height:34px;padding:0 13px;color:#626e7a;background:#0f1116;border-bottom:1px solid var(--border);font:500 .61rem/1 var(--font-family-monospace);text-transform:uppercase;letter-spacing:.08em}
.trackRow{min-height:62px;padding:0 13px;border-bottom:1px solid #242831}
.trackRow:last-child{border-bottom:0}
.trackRow:hover{background:#15181f}
.index,.added{color:#68737f;font:.65rem/1 var(--font-family-monospace)}
.trackMain{display:flex;align-items:center;gap:10px;min-width:0}
.art{width:36px;height:36px;display:grid;place-items:center;overflow:hidden;color:#6fcbd4;background:#1a1e26;border:1px solid #303641}
.art img{width:100%;height:100%;object-fit:cover}
.trackMain>div:last-child{min-width:0}
.trackMain strong,.trackMain span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.trackMain strong{font-size:.78rem}
.trackMain span,.detail{margin-top:3px;color:#6e7985;font-size:.67rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rowActions{display:flex;justify-content:flex-end;gap:2px}
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:#242831}
.card{display:flex;flex-direction:column;min-width:0;padding:12px;background:#111319}
.cardArt{aspect-ratio:1/1;display:grid;place-items:center;overflow:hidden;color:#6fcbd4;background:#1a1e26;border:1px solid #303641}
.cardArt img{width:100%;height:100%;object-fit:cover}
.cardBody{min-height:76px;padding:10px 0}
.cardBody strong,.cardBody span,.cardBody small{display:block}
.cardBody strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.79rem}
.cardBody span{margin-top:4px;color:#6e7985;font-size:.67rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cardBody small{margin-top:5px;color:#58636f;font-size:.62rem}
.cardActions{display:flex;gap:6px;flex-wrap:wrap}
.empty,.loading,.loadMore{display:flex;align-items:center;justify-content:center;gap:8px;min-height:70px;padding:16px;color:#6e7985;font-size:.72rem;border-top:1px solid var(--border)}
.loading svg{animation:spin .9s linear infinite}
.loadMore{min-height:58px}
.playerDock{position:fixed;left:0;right:0;bottom:0;z-index:25;padding:0 22px 14px;pointer-events:none}
.playerDock>*{pointer-events:auto}
@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:900px){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}.trackHeader,.trackRow{grid-template-columns:34px minmax(220px,1.5fr) minmax(140px,1fr) 72px}.trackHeader span:nth-child(4),.added{display:none}.playerDock{padding-inline:12px}}
@media(max-width:900px){
.page{padding:0 10px 154px}
.topbar{position:sticky;top:0;z-index:20;min-height:58px;grid-template-columns:auto 1fr;background:rgba(11,12,16,.97)}
.topbar img{width:132px;justify-self:end}.topbar>span{display:none}
.backLink{font-size:0}.backLink svg{width:20px;height:20px}
.heading{align-items:flex-start;flex-direction:column;gap:10px;padding:18px 2px 14px}
.heading h1{font-size:1.75rem}.heading p{font-size:.74rem;line-height:1.5}
.account{width:100%;box-sizing:border-box}
.panel{border-left:0;border-right:0}
.toolbar{align-items:stretch;flex-direction:column;gap:8px;padding:9px 8px;overflow:hidden}
.toolbar :global([role="tablist"]){width:100%;overflow-x:auto;justify-content:flex-start}
.count{padding-left:2px}
.grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.card{padding:8px}
.cardBody{min-height:66px;padding:8px 0}
.cardActions{display:grid;grid-template-columns:1fr;gap:5px}
.cardActions a{width:100%}
.trackHeader{display:none}
.trackRow{grid-template-columns:1fr auto;gap:8px;padding:9px 8px}
.index,.detail,.added{display:none}
.art{width:42px;height:42px}
.trackMain strong{font-size:.76rem}
.trackMain span{font-size:.63rem}
.playerDock{bottom:68px;padding:6px 7px;background:#0b0c10;border-top:1px solid #242831}
}
+3
View File
@@ -0,0 +1,3 @@
import { AppShell } from "../components/AppShell";
export default [AppShell];
+340
View File
@@ -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>
);
}
+60
View File
@@ -0,0 +1,60 @@
.page {
min-height: 100vh;
display: grid;
place-items: center;
align-content: center;
gap: 26px;
padding: 24px;
color: var(--foreground);
background: var(--background);
font-family: var(--font-family-base);
}
.page > img {
width: min(380px, 78vw);
max-height: 112px;
object-fit: contain;
}
.message {
width: min(420px, 100%);
padding: 24px;
text-align: center;
background: var(--card);
border: 1px solid var(--border);
border-radius: 4px;
}
.message > svg {
color: var(--primary);
}
.message strong {
display: block;
margin-top: 10px;
font: 600 1.05rem/1.2 var(--font-family-display);
}
.message p {
margin: 8px 0 0;
color: var(--muted-foreground);
font-size: .84rem;
}
.message button {
margin-top: 18px;
padding: 8px 14px;
color: var(--primary-foreground);
background: var(--primary);
border: 0;
border-radius: 3px;
cursor: pointer;
}
.spinner {
animation: spin .9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+1
View File
@@ -0,0 +1 @@
export default []
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { CircleCheck, LoaderCircle, TriangleAlert } from "lucide-react";
import { postSpotifyExchange } from "../endpoints/spotify-exchange_POST.schema";
import { getDesktopBridge } from "../helpers/desktopBridge";
import styles from "./spotify.callback.module.css";
export default function SpotifyCallbackPage() {
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [handedToApp, setHandedToApp] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const spotifyError = params.get("error");
const code = params.get("code");
const state = params.get("state");
if (spotifyError) {
setError(`Spotify authorization was not completed: ${spotifyError}`);
return;
}
if (!code || !state) {
setError("Spotify did not return the expected authorization information.");
return;
}
postSpotifyExchange({ code, state })
.then((result) => {
if (result.desktop && !getDesktopBridge()) setHandedToApp(true);
else navigate("/", { replace: true });
})
.catch((reason) => setError(reason instanceof Error ? reason.message : "Spotify login failed."));
}, [navigate]);
return (
<main className={styles.page}>
<img src="/SpotiRip.png" alt="Aurora SpotiRip" />
{error ? (
<div className={styles.message}>
<TriangleAlert size={22} />
<strong>Spotify connection failed</strong>
<p>{error}</p>
<button onClick={() => navigate("/", { replace: true })}>Back to SpotiRip</button>
</div>
) : handedToApp ? (
<div className={styles.message}>
<CircleCheck size={24} />
<strong>Spotify connected</strong>
<p>Return to Aurora SpotiRip. You can close this tab.</p>
</div>
) : (
<div className={styles.message}>
<LoaderCircle className={styles.spinner} size={24} />
<strong>Connecting Spotify…</strong>
<p>Finishing authorization and loading your account.</p>
</div>
)}
</main>
);
}