import { useState, useCallback, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { AudioLinesIcon } from "@/components/ui/audio-lines"; interface AudioFile { path: string; name: string; format: string; size: number; status: "pending" | "resampling" | "success" | "error"; error?: string; outputPath?: string; srcSampleRate?: number; srcBitDepth?: number; } function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; } function formatSampleRate(sr: number): string { if (!sr) return ""; if (sr === 44100) return "44.1kHz"; if (sr >= 1000) return `${sr / 1000}kHz`; return `${sr}Hz`; } const SAMPLE_RATE_OPTIONS = [ { value: "44100", label: "44.1kHz" }, { value: "48000", label: "48kHz" }, { value: "96000", label: "96kHz" }, { value: "192000", label: "192kHz" }, ]; const BIT_DEPTH_OPTIONS = [ { value: "16", label: "16-bit" }, { value: "24", label: "24-bit" }, ]; const STORAGE_KEY = "spotiflac_audio_resampler_state"; export function AudioResamplerPage() { const [files, setFiles] = useState(() => { try { const saved = sessionStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) { return parsed.files; } } } catch (err) { console.error("Failed to load saved state:", err); } return []; }); const [sampleRate, setSampleRate] = useState(() => { try { const saved = sessionStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); if (parsed.sampleRate) return parsed.sampleRate; } } catch (err) { } return "44100"; }); const [bitDepth, setBitDepth] = useState(() => { try { const saved = sessionStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); if (parsed.bitDepth) return parsed.bitDepth; } } catch (err) { } return "16"; }); const [resampling, setResampling] = useState(false); const [isDragging, setIsDragging] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const saveState = useCallback((stateToSave: { files: AudioFile[]; sampleRate: string; bitDepth: string; }) => { try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); } catch (err) { console.error("Failed to save state:", err); } }, []); useEffect(() => { saveState({ files, sampleRate, bitDepth }); }, [files, sampleRate, bitDepth, saveState]); useEffect(() => { const checkFullscreen = () => { const isMaximized = window.innerHeight >= window.screen.height * 0.9; setIsFullscreen(isMaximized); }; checkFullscreen(); window.addEventListener("resize", checkFullscreen); window.addEventListener("focus", checkFullscreen); return () => { window.removeEventListener("resize", checkFullscreen); window.removeEventListener("focus", checkFullscreen); }; }, []); const fetchAudioInfo = useCallback(async (paths: string[]) => { if (paths.length === 0) return; try { const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"]; const infos: Array<{ path: string; sample_rate: number; bits_per_sample: number; }> = await GetFlacInfoBatch(paths); setFiles((prev) => prev.map((f) => { const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase()); if (info) { return { ...f, srcSampleRate: info.sample_rate || undefined, srcBitDepth: info.bits_per_sample || undefined, }; } return f; })); } catch (err) { console.error("Failed to fetch audio info:", err); } }, []); const handleSelectFiles = async () => { try { const selectedFiles = await SelectAudioFiles(); if (selectedFiles && selectedFiles.length > 0) { addFiles(selectedFiles); } } catch (err) { toast.error("File Selection Failed", { description: err instanceof Error ? err.message : "Failed to select files", }); } }; const handleSelectFolder = async () => { try { const selectedFolder = await SelectFolder(""); if (selectedFolder) { const folderFiles = await ListAudioFilesInDir(selectedFolder); if (folderFiles && folderFiles.length > 0) { addFiles(folderFiles.map((f) => f.path)); } else { toast.info("No audio files found", { description: "No FLAC files found in the selected folder.", }); } } } catch (err) { toast.error("Folder Selection Failed", { description: err instanceof Error ? err.message : "Failed to select folder", }); } }; const addFiles = useCallback(async (paths: string[]) => { const validExtensions = [".flac"]; const invalidFiles = paths.filter((path) => { const ext = path.toLowerCase().slice(path.lastIndexOf(".")); return !validExtensions.includes(ext); }); if (invalidFiles.length > 0) { toast.error("Unsupported format", { description: "Only FLAC files are supported for resampling.", }); } const GetFileSizes = (files: string[]): Promise> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files); const validPaths = paths.filter((path) => { const ext = path.toLowerCase().slice(path.lastIndexOf(".")); return validExtensions.includes(ext); }); const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {}; let newlyAddedPaths: string[] = []; setFiles((prev) => { const newFiles: AudioFile[] = validPaths .filter((path) => !prev.some((f) => f.path === path)) .map((path) => { const name = path.split(/[/\\]/).pop() || path; const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase(); return { path, name, format: ext, size: fileSizes[path] || 0, status: "pending" as const, }; }); newlyAddedPaths = newFiles.map((f) => f.path); if (newFiles.length > 0) { if (paths.length > newFiles.length + invalidFiles.length) { const skipped = paths.length - newFiles.length - invalidFiles.length; toast.info("Some files skipped", { description: `${skipped} file(s) were already added`, }); } return [...prev, ...newFiles]; } if (validPaths.length > 0 && newFiles.length === 0) { toast.info("No new files added", { description: "All valid files were already added", }); } return prev; }); setTimeout(() => { if (newlyAddedPaths.length > 0) { fetchAudioInfo(newlyAddedPaths); } }, 50); }, [fetchAudioInfo]); const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => { setIsDragging(false); if (paths.length === 0) return; addFiles(paths); }, [addFiles]); useEffect(() => { OnFileDrop((x, y, paths) => { handleFileDrop(x, y, paths); }, true); return () => { OnFileDropOff(); }; }, [handleFileDrop]); const removeFile = (path: string) => { setFiles((prev) => prev.filter((f) => f.path !== path)); }; const clearFiles = () => { setFiles([]); }; const handleResample = async () => { if (files.length === 0) { toast.error("No files selected", { description: "Please add FLAC files to resample", }); return; } setResampling(true); try { const inputPaths = files.map((f) => f.path); setFiles((prev) => prev.map((f) => { if (inputPaths.includes(f.path)) { return { ...f, status: "resampling" as const, error: undefined }; } return f; })); const results = await ResampleAudio({ input_files: inputPaths, sample_rate: sampleRate, bit_depth: bitDepth, }); setFiles((prev) => prev.map((f) => { const result = results.find((r: any) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase()); if (result) { return { ...f, status: result.success ? "success" : "error", error: result.error, outputPath: result.output_file, }; } return f; })); const successCount = results.filter((r: any) => r.success).length; const failCount = results.filter((r: any) => !r.success).length; if (successCount > 0) { toast.success("Resampling Complete", { description: `Successfully resampled ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, }); } else if (failCount > 0) { toast.error("Resampling Failed", { description: `All ${failCount} file(s) failed to resample`, }); } } catch (err) { toast.error("Resampling Error", { description: err instanceof Error ? err.message : "Unknown error", }); setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Resampling failed" }))); } finally { setResampling(false); } }; const getStatusIcon = (status: AudioFile["status"]) => { switch (status) { case "resampling": return ; case "success": return ; case "error": return ; default: return ; } }; const resampleableCount = files.filter((f) => f.status === "pending" || f.status === "success").length; const successCount = files.filter((f) => f.status === "success").length; return (

Audio Resampler

{files.length > 0 && (
)}
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} onDrop={(e) => { e.preventDefault(); setIsDragging(false); }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}> {files.length === 0 ? (<>

{isDragging ? "Drop your FLAC files here" : "Drag and drop FLAC files here, or click the button below to select"}

Supported format: FLAC

) : (
{ if (value) setBitDepth(value); }}> {BIT_DEPTH_OPTIONS.map((option) => ( {option.label} ))}
{ if (value) setSampleRate(value); }}> {SAMPLE_RATE_OPTIONS.map((option) => ( {option.label} ))}
{files.length} file(s) • {successCount} resampled
{files.map((file) => { const srcParts: string[] = []; if (file.srcBitDepth) srcParts.push(`${file.srcBitDepth}-bit`); if (file.srcSampleRate) srcParts.push(formatSampleRate(file.srcSampleRate)); const srcSpec = srcParts.join(" / "); return (
{getStatusIcon(file.status)}

{file.name}

{file.error && (

{file.error}

)}
{srcSpec ? ( {srcSpec} ) : file.status === "pending" ? ( reading... ) : null} {formatFileSize(file.size)} {file.format} {file.status !== "resampling" && ()}
); })}
)}
); }