v6.9
This commit is contained in:
@@ -584,18 +584,18 @@ export function AudioConverterPage() {
|
||||
<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-2 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4 text-center">
|
||||
Supported formats: FLAC, MP3
|
||||
</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">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||
|
||||
@@ -9,17 +9,18 @@ interface DownloadProgressProps {
|
||||
}
|
||||
|
||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
return (
|
||||
<div className="w-full space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={progress} className="h-2 flex-1" />
|
||||
<Progress value={clampedProgress} className="h-2 flex-1" />
|
||||
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
|
||||
<StopCircle className="h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress}% -{" "}
|
||||
{clampedProgress}% -{" "}
|
||||
{currentTrack
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
|
||||
@@ -36,6 +36,12 @@ const PreviewRenameFiles = (files: string[], format: string): Promise<backend.Re
|
||||
(window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
|
||||
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> =>
|
||||
(window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
|
||||
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> =>
|
||||
(window as any)['go']['main']['App']['ReadFileMetadata'](path);
|
||||
const IsFFprobeInstalled = (): Promise<boolean> =>
|
||||
(window as any)['go']['main']['App']['IsFFprobeInstalled']();
|
||||
const DownloadFFmpeg = (): Promise<{ success: boolean; message: string; error?: string }> =>
|
||||
(window as any)['go']['main']['App']['DownloadFFmpeg']();
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import {
|
||||
@@ -67,6 +73,16 @@ interface FileNode {
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
interface FileMetadata {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
album_artist: string;
|
||||
track_number: number;
|
||||
disc_number: number;
|
||||
year: string;
|
||||
}
|
||||
|
||||
const FORMAT_PRESETS: Record<string, { label: string; template: string }> = {
|
||||
"title": { label: "Title", template: "{title}" },
|
||||
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||
@@ -139,6 +155,12 @@ export function FileManagerPage() {
|
||||
const [previewOnly, setPreviewOnly] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showMetadata, setShowMetadata] = useState(false);
|
||||
const [metadataFile, setMetadataFile] = useState<string>("");
|
||||
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
|
||||
const [loadingMetadata, setLoadingMetadata] = useState(false);
|
||||
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
|
||||
const [installingFFprobe, setInstallingFFprobe] = useState(false);
|
||||
|
||||
// Save state to sessionStorage
|
||||
useEffect(() => {
|
||||
@@ -177,9 +199,13 @@ export function FileManagerPage() {
|
||||
setFiles(filtered);
|
||||
setSelectedFiles(new Set());
|
||||
} catch (err) {
|
||||
toast.error("Failed to load files", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
// Don't show error toast for empty directory or no files found
|
||||
const errorMsg = err instanceof Error ? err.message : "";
|
||||
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
|
||||
toast.error("Failed to load files", {
|
||||
description: errorMsg || "Unknown error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -253,6 +279,32 @@ export function FileManagerPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFolderSelect = (node: FileNode) => {
|
||||
const folderFiles = getAllAudioFiles([node]);
|
||||
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (allSelected) {
|
||||
// Deselect all files in folder
|
||||
folderFiles.forEach((f) => newSet.delete(f.path));
|
||||
} else {
|
||||
// Select all files in folder
|
||||
folderFiles.forEach((f) => newSet.add(f.path));
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
|
||||
const folderFiles = getAllAudioFiles([node]);
|
||||
if (folderFiles.length === 0) return false;
|
||||
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
|
||||
if (selectedCount === 0) return false;
|
||||
if (selectedCount === folderFiles.length) return true;
|
||||
return "indeterminate";
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const allAudioFiles = getAllAudioFiles(files);
|
||||
setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
|
||||
@@ -287,7 +339,16 @@ export function FileManagerPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// Check if any selected file is M4A and ffprobe is not installed
|
||||
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
|
||||
if (hasM4A) {
|
||||
const installed = await IsFFprobeInstalled();
|
||||
if (!installed) {
|
||||
setShowFFprobeDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
|
||||
setPreviewData(result);
|
||||
@@ -297,8 +358,55 @@ export function FileManagerPage() {
|
||||
toast.error("Failed to generate preview", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if M4A file needs ffprobe
|
||||
if (filePath.toLowerCase().endsWith(".m4a")) {
|
||||
const installed = await IsFFprobeInstalled();
|
||||
if (!installed) {
|
||||
setShowFFprobeDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setMetadataFile(filePath);
|
||||
setLoadingMetadata(true);
|
||||
try {
|
||||
const metadata = await ReadFileMetadata(filePath);
|
||||
setMetadataInfo(metadata as FileMetadata);
|
||||
setShowMetadata(true);
|
||||
} catch (err) {
|
||||
toast.error("Failed to read metadata", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
setMetadataInfo(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMetadata(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallFFprobe = async () => {
|
||||
setInstallingFFprobe(true);
|
||||
try {
|
||||
const result = await DownloadFFmpeg();
|
||||
if (result.success) {
|
||||
toast.success("FFprobe installed successfully");
|
||||
setShowFFprobeDialog(false);
|
||||
} else {
|
||||
toast.error("Failed to install FFprobe", {
|
||||
description: result.error || result.message,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Failed to install FFprobe", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setInstallingFFprobe(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -345,6 +453,19 @@ export function FileManagerPage() {
|
||||
>
|
||||
{node.is_dir ? (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={isFolderSelected(node) === true}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
(el as HTMLButtonElement).dataset.state =
|
||||
isFolderSelected(node) === "indeterminate" ? "indeterminate" :
|
||||
isFolderSelected(node) ? "checked" : "unchecked";
|
||||
}
|
||||
}}
|
||||
onCheckedChange={() => toggleFolderSelect(node)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"
|
||||
/>
|
||||
{node.expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
@@ -363,9 +484,25 @@ export function FileManagerPage() {
|
||||
<FileMusic className="h-4 w-4 text-primary shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate text-sm flex-1">{node.name}</span>
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllAudioFiles([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted shrink-0"
|
||||
onClick={(e) => handleShowMetadata(node.path, e)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Metadata</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && (
|
||||
@@ -440,7 +577,7 @@ export function FileManagerPage() {
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset to default</TooltipContent>
|
||||
<TooltipContent>Reset to Default</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -452,12 +589,12 @@ export function FileManagerPage() {
|
||||
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -538,11 +675,11 @@ export function FileManagerPage() {
|
||||
className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}
|
||||
>
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground truncate">{item.old_name}</div>
|
||||
<div className="text-muted-foreground break-all">{item.old_name}</div>
|
||||
{item.error ? (
|
||||
<div className="text-destructive text-xs mt-1">{item.error}</div>
|
||||
) : (
|
||||
<div className="text-primary font-medium truncate mt-1">→ {item.new_name}</div>
|
||||
<div className="text-primary font-medium break-all mt-1">→ {item.new_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -577,6 +714,100 @@ export function FileManagerPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Metadata Dialog */}
|
||||
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between pr-2">
|
||||
<DialogTitle>File Metadata</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full hover:bg-muted"
|
||||
onClick={() => setShowMetadata(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogDescription className="break-all">
|
||||
{metadataFile.split(/[/\\]/).pop()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loadingMetadata ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : metadataInfo ? (
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Title</span>
|
||||
<span>{metadataInfo.title || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Artist</span>
|
||||
<span>{metadataInfo.artist || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Album</span>
|
||||
<span>{metadataInfo.album || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Album Artist</span>
|
||||
<span>{metadataInfo.album_artist || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Track</span>
|
||||
<span>{metadataInfo.track_number || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Disc</span>
|
||||
<span>{metadataInfo.disc_number || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Year</span>
|
||||
<span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No metadata available
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowMetadata(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* FFprobe Install Dialog */}
|
||||
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>FFprobe Required</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reading M4A metadata requires FFprobe. Would you like to download and install it now?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
|
||||
{installingFFprobe ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
"Install FFprobe"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user