Files
SpotiFLAC/frontend/src/components/LyricsManagerPage.tsx
T
429Enjoyer 954cfe9d4f v7.1.8
2026-06-09 06:06:52 +07:00

328 lines
15 KiB
TypeScript

import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Upload, X, FileText, Trash2, AlertCircle, Music, Clock, Download } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { ReadEmbeddedLyrics, SelectLyricsFiles, ExtractLyricsToLRC } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface LyricsFile {
path: string;
name: string;
format: string;
lyrics: string;
source: string;
synced: boolean;
status: "loading" | "loaded" | "empty" | "error";
error?: string;
}
const SUPPORTED_EXTENSIONS = [".lrc", ".txt", ".flac", ".mp3", ".m4a", ".aac", ".opus", ".ogg"];
function getExtension(path: string): string {
const lower = path.toLowerCase();
const dot = lower.lastIndexOf(".");
return dot >= 0 ? lower.slice(dot) : "";
}
export function LyricsManagerPage() {
const [files, setFiles] = useState<LyricsFile[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [extracting, setExtracting] = useState(false);
useEffect(() => {
const checkFullscreen = () => {
setIsFullscreen(window.innerHeight >= window.screen.height * 0.9);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const addFiles = useCallback(async (paths: string[]) => {
const validPaths = paths.filter((path) => SUPPORTED_EXTENSIONS.includes(getExtension(path)));
if (validPaths.length === 0) {
if (paths.length > 0) {
toast.error("Unsupported files", {
description: "Only LRC and audio files (FLAC, MP3, M4A) are supported.",
});
}
return;
}
const newPaths: string[] = [];
setFiles((prev) => {
const toAdd = validPaths.filter((path) => !prev.some((f) => f.path === path));
newPaths.push(...toAdd);
const entries: LyricsFile[] = toAdd.map((path) => {
const name = path.split(/[/\\]/).pop() || path;
return {
path,
name,
format: getExtension(path).slice(1),
lyrics: "",
source: "",
synced: false,
status: "loading" as const,
};
});
if (entries.length === 0) {
return prev;
}
return [...prev, ...entries];
});
for (const path of newPaths) {
try {
const result = await ReadEmbeddedLyrics(path);
setFiles((prev) => prev.map((f) => {
if (f.path !== path)
return f;
if (result.error) {
return { ...f, status: "empty" as const, error: result.error };
}
return {
...f,
lyrics: result.lyrics,
source: result.source,
synced: result.synced,
status: "loaded" as const,
};
}));
}
catch (err) {
setFiles((prev) => prev.map((f) => f.path === path
? { ...f, status: "error" as const, error: err instanceof Error ? err.message : "Failed to read lyrics" }
: f));
}
}
setSelectedPath((prev) => prev ?? newPaths[0] ?? null);
}, []);
const handleSelectFiles = async () => {
try {
const selected = await SelectLyricsFiles();
if (selected && selected.length > 0) {
addFiles(selected);
}
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select files",
});
}
};
const handleFileDrop = useCallback((_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) => {
const next = prev.filter((f) => f.path !== path);
setSelectedPath((current) => {
if (current !== path)
return current;
return next[0]?.path ?? null;
});
return next;
});
};
const clearFiles = () => {
setFiles([]);
setSelectedPath(null);
};
const selectedFile = files.find((f) => f.path === selectedPath) || null;
const extractFile = async (file: LyricsFile, overwrite: boolean) => {
const result = await ExtractLyricsToLRC(file.path, overwrite);
if (result.success) {
return { ok: true as const, output: result.output_path };
}
if (result.already_exists) {
return { ok: false as const, alreadyExists: true, output: result.output_path };
}
return { ok: false as const, error: result.error || "Failed to extract lyrics" };
};
const handleExtractSelected = async () => {
if (!selectedFile || selectedFile.status !== "loaded")
return;
setExtracting(true);
try {
const result = await extractFile(selectedFile, false);
if (result.ok) {
toast.success("Lyrics extracted", { description: result.output });
}
else if (result.alreadyExists) {
toast.info("LRC already exists", {
description: "A .lrc file with the same name already exists next to this file.",
});
}
else {
toast.error("Extract failed", { description: result.error });
}
}
catch (err) {
toast.error("Extract failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
}
finally {
setExtracting(false);
}
};
const handleExtractAll = async () => {
const extractable = files.filter((f) => f.status === "loaded");
if (extractable.length === 0) {
toast.error("Nothing to extract", {
description: "No files with embedded lyrics are loaded.",
});
return;
}
setExtracting(true);
let success = 0;
let skipped = 0;
let failed = 0;
for (const file of extractable) {
try {
const result = await extractFile(file, false);
if (result.ok)
success++;
else if (result.alreadyExists)
skipped++;
else
failed++;
}
catch {
failed++;
}
}
setExtracting(false);
if (success > 0) {
toast.success("Lyrics extracted", {
description: `${success} file(s) extracted${skipped > 0 ? `, ${skipped} skipped` : ""}${failed > 0 ? `, ${failed} failed` : ""}`,
});
}
else if (skipped > 0 && failed === 0) {
toast.info("Already extracted", {
description: `${skipped} .lrc file(s) already exist.`,
});
}
else {
toast.error("Extract failed", {
description: `${failed} file(s) failed to extract.`,
});
}
};
const embeddedLoadedCount = files.filter((f) => f.status === "loaded" && f.source === "embedded").length;
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Lyrics Manager</h1>
{files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/>
Add Files
</Button>
{embeddedLoadedCount > 0 && (<Button variant="outline" size="sm" onClick={handleExtractAll} disabled={extracting}>
{extracting ? <Spinner className="h-4 w-4"/> : <Download className="h-4 w-4"/>}
Extract All
</Button>)}
<Button variant="outline" size="sm" onClick={clearFiles} disabled={extracting}>
<Trash2 className="h-4 w-4"/>
Clear All
</Button>
</div>)}
</div>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "min-h-[400px]"} ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"}`} onDragOver={(e) => {
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 ? (<>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your files here"
: "Drag and drop LRC or audio files here, or click the button below to select"}
</p>
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5"/>
Select Files
</Button>
<p className="text-xs text-muted-foreground mt-4 text-center">
Reads embedded lyrics from FLAC, MP3, M4A, Opus or plain LRC files
</p>
</>) : (<div className="w-full h-full p-4 flex flex-col md:flex-row gap-4 min-h-0">
<div className="md:w-64 shrink-0 flex flex-col gap-2 md:border-r md:pr-4 max-h-48 md:max-h-none overflow-y-auto">
{files.map((file) => {
const isActive = file.path === selectedPath;
return (<button key={file.path} onClick={() => setSelectedPath(file.path)} className={`group flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${isActive ? "border-primary bg-primary/10" : "hover:bg-muted/60"}`}>
{file.status === "loading" ? (<Spinner className="h-4 w-4 shrink-0 text-primary"/>)
: file.status === "error" || file.status === "empty" ? (<AlertCircle className="h-4 w-4 shrink-0 text-destructive"/>)
: (<FileText className="h-4 w-4 shrink-0 text-muted-foreground"/>)}
<div className="flex-1 min-w-0">
<p className="truncate text-xs font-medium">{file.name}</p>
<p className="truncate text-[10px] uppercase text-muted-foreground">{file.format}</p>
</div>
<span role="button" tabIndex={-1} onClick={(e) => { e.stopPropagation(); removeFile(file.path); }} className="opacity-0 group-hover:opacity-100 transition-opacity rounded p-1 hover:bg-muted">
<X className="h-3.5 w-3.5"/>
</span>
</button>);
})}
</div>
<div className="flex-1 min-w-0 flex flex-col min-h-0">
{!selectedFile ? (<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Select a file to view its lyrics
</div>) : selectedFile.status === "loading" ? (<div className="flex-1 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Spinner className="h-4 w-4"/>
Reading lyrics...
</div>) : selectedFile.status === "error" || selectedFile.status === "empty" ? (<div className="flex-1 flex flex-col items-center justify-center gap-2 text-center px-6">
<AlertCircle className="h-8 w-8 text-destructive"/>
<p className="text-sm font-medium">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">{selectedFile.error || "No lyrics found"}</p>
</div>) : (<>
<div className="flex flex-col gap-2 pb-3 border-b shrink-0">
<div className="flex items-center gap-2 min-w-0">
<p className="truncate text-sm font-medium flex-1">{selectedFile.name}</p>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase shrink-0">
{selectedFile.source === "lrc" ? (<><FileText className="h-3 w-3"/> LRC</>) : (<><Music className="h-3 w-3"/> Embedded</>)}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase shrink-0">
<Clock className="h-3 w-3"/>
{selectedFile.synced ? "Synced" : "Plain"}
</span>
</div>
<div className="flex items-center gap-2">
{selectedFile.source === "embedded" && (<Button variant="outline" size="sm" onClick={handleExtractSelected} disabled={extracting}>
{extracting ? <Spinner className="h-4 w-4"/> : <Download className="h-4 w-4"/>}
Extract LRC
</Button>)}
</div>
</div>
<div className="flex-1 overflow-y-auto pt-3 min-h-0">
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-foreground/90">{selectedFile.lyrics}</pre>
</div>
</>)}
</div>
</div>)}
</div>
</div>);
}