v7.1.8
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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>);
|
||||
}
|
||||
Reference in New Issue
Block a user