This commit is contained in:
afkarxyz
2025-12-19 21:04:12 +07:00
parent 436feb7f7c
commit 66c30de2db
8 changed files with 491 additions and 80 deletions
@@ -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">
+3 -2
View File
@@ -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..."}
+244 -13
View File
@@ -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>
);
}
+4 -2
View File
@@ -757,7 +757,8 @@ export function useDownload() {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100));
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
}
setDownloadingTrack(null);
@@ -938,7 +939,8 @@ export function useDownload() {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100));
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
}
setDownloadingTrack(null);
+10
View File
@@ -229,3 +229,13 @@ export interface CoverDownloadResponse {
}
export interface AudioMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}