Files
R3ap3r 3178b8f4f7 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
2026-09-25 23:20:57 -05:00

198 lines
7.4 KiB
TypeScript

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>
);
}