- 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
198 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|