import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { InputWithContext } from "@/components/ui/input-with-context"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { FolderOpen, RefreshCw, FileMusic, ChevronRight, ChevronDown, Pencil, Eye, Folder, Info, RotateCcw, FileText, Image, Copy, Check, } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Spinner } from "@/components/ui/spinner"; import { Badge } from "@/components/ui/badge"; import { SelectFolder } from "../../wailsjs/go/main/App"; import { backend } from "../../wailsjs/go/models"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { getSettings } from "@/lib/settings"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; const ListDirectoryFiles = (path: string): Promise => (window as any)['go']['main']['App']['ListDirectoryFiles'](path); const PreviewRenameFiles = (files: string[], format: string): Promise => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format); const RenameFilesByMetadata = (files: string[], format: string): Promise => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format); const ReadFileMetadata = (path: string): Promise => (window as any)['go']['main']['App']['ReadFileMetadata'](path); const ReadTextFile = (path: string): Promise => (window as any)['go']['main']['App']['ReadTextFile'](path); const RenameFileTo = (oldPath: string, newName: string): Promise => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName); const ReadImageAsBase64 = (path: string): Promise => (window as any)['go']['main']['App']['ReadImageAsBase64'](path); interface FileNode { name: string; path: string; is_dir: boolean; size: number; children?: FileNode[]; expanded?: boolean; } interface FileMetadata { title: string; artist: string; album: string; album_artist: string; track_number: number; disc_number: number; year: string; } type TabType = "track" | "lyric" | "cover"; const FORMAT_PRESETS: Record = { "title": { label: "Title", template: "{title}" }, "title-artist": { label: "Title - Artist", template: "{title} - {artist}" }, "artist-title": { label: "Artist - Title", template: "{artist} - {title}" }, "track-title": { label: "Track. Title", template: "{track}. {title}" }, "track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" }, "track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" }, "title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" }, "track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" }, "artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" }, "track-dash-title": { label: "Track - Title", template: "{track} - {title}" }, "disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" }, "disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" }, "custom": { label: "Custom...", template: "{title} - {artist}" }, }; const STORAGE_KEY = "spotiflac_file_manager_state"; const DEFAULT_PRESET = "title-artist"; const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}"; 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]; } export function FileManagerPage() { const [rootPath, setRootPath] = useState(() => { const settings = getSettings(); return settings.downloadPath || ""; }); const [allFiles, setAllFiles] = useState([]); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [loading, setLoading] = useState(false); const [activeTab, setActiveTab] = useState("track"); const [formatPreset, setFormatPreset] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) { return parsed.formatPreset; } } } catch { } return DEFAULT_PRESET; }); const [customFormat, setCustomFormat] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); if (parsed.customFormat) return parsed.customFormat; } } catch { } return DEFAULT_CUSTOM_FORMAT; }); const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template; const [showPreview, setShowPreview] = useState(false); const [previewData, setPreviewData] = useState([]); const [renaming, setRenaming] = useState(false); const [previewOnly, setPreviewOnly] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); const [showMetadata, setShowMetadata] = useState(false); const [metadataFile, setMetadataFile] = useState(""); const [metadataInfo, setMetadataInfo] = useState(null); const [loadingMetadata, setLoadingMetadata] = useState(false); const [showLyricsPreview, setShowLyricsPreview] = useState(false); const [lyricsContent, setLyricsContent] = useState(""); const [lyricsFile, setLyricsFile] = useState(""); const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced"); const [copySuccess, setCopySuccess] = useState(false); const [showCoverPreview, setShowCoverPreview] = useState(false); const [coverFile, setCoverFile] = useState(""); const [coverData, setCoverData] = useState(""); const [showManualRename, setShowManualRename] = useState(false); const [manualRenameFile, setManualRenameFile] = useState(""); const [manualRenameName, setManualRenameName] = useState(""); const [manualRenaming, setManualRenaming] = useState(false); useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat })); } catch { } }, [formatPreset, customFormat]); 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 filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => { return nodes .map((node) => { if (node.is_dir && node.children) { const filteredChildren = filterFilesByType(node.children, type); if (filteredChildren.length > 0) { return { ...node, children: filteredChildren }; } return null; } const ext = node.name.toLowerCase(); if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a"))) return node; if (type === "lyric" && ext.endsWith(".lrc")) return node; if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png"))) return node; return null; }) .filter((node): node is FileNode => node !== null); }; const loadFiles = useCallback(async () => { if (!rootPath) return; setLoading(true); try { const result = await ListDirectoryFiles(rootPath); if (!result || !Array.isArray(result)) { setAllFiles([]); setSelectedFiles(new Set()); return; } setAllFiles(result as FileNode[]); setSelectedFiles(new Set()); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err || ""); if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) { toast.error("Failed to load files", { description: errorMsg || "Unknown error" }); } setAllFiles([]); setSelectedFiles(new Set()); } finally { setLoading(false); } }, [rootPath]); useEffect(() => { if (rootPath) loadFiles(); }, [rootPath, loadFiles]); const filteredFiles = filterFilesByType(allFiles, activeTab); const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => { const result: FileNode[] = []; for (const node of nodes) { if (!node.is_dir) result.push(node); if (node.children) result.push(...getAllFilesFlat(node.children)); } return result; }; const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track")); const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric")); const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover")); const handleSelectFolder = async () => { try { const path = await SelectFolder(rootPath); if (path) setRootPath(path); } catch (err) { toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" }); } }; const toggleExpand = (path: string) => { setAllFiles((prev) => toggleNodeExpand(prev, path)); }; const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => { return nodes.map((node) => { if (node.path === path) return { ...node, expanded: !node.expanded }; if (node.children) return { ...node, children: toggleNodeExpand(node.children, path) }; return node; }); }; const toggleSelect = (path: string) => { setSelectedFiles((prev) => { const newSet = new Set(prev); if (newSet.has(path)) newSet.delete(path); else newSet.add(path); return newSet; }); }; const toggleFolderSelect = (node: FileNode) => { const folderFiles = getAllFilesFlat([node]); const allSelected = folderFiles.every((f) => selectedFiles.has(f.path)); setSelectedFiles((prev) => { const newSet = new Set(prev); if (allSelected) folderFiles.forEach((f) => newSet.delete(f.path)); else folderFiles.forEach((f) => newSet.add(f.path)); return newSet; }); }; const isFolderSelected = (node: FileNode): boolean | "indeterminate" => { const folderFiles = getAllFilesFlat([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 = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path))); const deselectAll = () => setSelectedFiles(new Set()); const resetToDefault = () => { setFormatPreset(DEFAULT_PRESET); setCustomFormat(DEFAULT_CUSTOM_FORMAT); setShowResetConfirm(false); }; const handlePreview = async (isPreviewOnly: boolean) => { if (selectedFiles.size === 0) { toast.error("No files selected"); return; } try { const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat); setPreviewData(result); setPreviewOnly(isPreviewOnly); setShowPreview(true); } catch (err) { toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" }); } }; const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => { e.stopPropagation(); 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 { setLoadingMetadata(false); } }; const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => { e.stopPropagation(); setLyricsFile(filePath); setLyricsTab("synced"); try { const content = await ReadTextFile(filePath); setLyricsContent(content); setShowLyricsPreview(true); } catch (err) { toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" }); } }; const handleShowCover = async (filePath: string, e: React.MouseEvent) => { e.stopPropagation(); setCoverFile(filePath); try { const data = await ReadImageAsBase64(filePath); setCoverData(data); setShowCoverPreview(true); } catch (err) { toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" }); } }; const getPlainLyrics = (content: string) => { return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim(); }; const formatTimestamp = (timestamp: string): string => { const match = timestamp.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/); if (!match) return timestamp; const minutes = parseInt(match[1], 10); const seconds = match[2]; return `${minutes}:${seconds}`; }; const renderSyncedLyrics = (content: string) => { if (!content) return
No lyrics content
; const lines = content.split('\n'); return lines.map((line, index) => { if (line.match(/^\[(ti|ar|al|by|length|offset):/i)) return null; const match = line.match(/^(\[[\d:.]+\])(.*)$/); if (match) { const timestamp = match[1]; const text = match[2].trim(); if (!text) return null; return (
{formatTimestamp(timestamp)} {text}
); } if (!line.trim()) return null; return (
{line}
); }).filter(item => item !== null); }; const handleCopyLyrics = async () => { try { const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent); await navigator.clipboard.writeText(textToCopy); setCopySuccess(true); setTimeout(() => setCopySuccess(false), 500); } catch { toast.error("Failed to copy lyrics"); } }; const handleManualRename = (filePath: string, e: React.MouseEvent) => { e.stopPropagation(); const fileName = filePath.split(/[/\\]/).pop() || ""; const nameWithoutExt = fileName.replace(/\.[^.]+$/, ""); setManualRenameFile(filePath); setManualRenameName(nameWithoutExt); setShowManualRename(true); }; const handleConfirmManualRename = async () => { if (!manualRenameFile || !manualRenameName.trim()) return; setManualRenaming(true); try { await RenameFileTo(manualRenameFile, manualRenameName.trim()); toast.success("File renamed successfully"); setShowManualRename(false); loadFiles(); } catch (err) { toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" }); } finally { setManualRenaming(false); } }; const handleRename = async () => { if (selectedFiles.size === 0) return; setRenaming(true); try { const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat); const successCount = result.filter((r: backend.RenameResult) => r.success).length; const failCount = result.filter((r: backend.RenameResult) => !r.success).length; if (successCount > 0) toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` }); else toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` }); setShowPreview(false); setSelectedFiles(new Set()); loadFiles(); } catch (err) { toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" }); } finally { setRenaming(false); } }; const renderTrackTree = (nodes: FileNode[], depth = 0) => { return nodes.map((node) => (
(node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}> {node.is_dir ? (<> { 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 ? : } ) : (<> toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/> )} {node.name} {node.is_dir && ({getAllFilesFlat([node]).length})} {!node.is_dir && (<> {formatFileSize(node.size)} View Metadata )}
{node.is_dir && node.expanded && node.children &&
{renderTrackTree(node.children, depth + 1)}
}
)); }; const renderLyricTree = (nodes: FileNode[], depth = 0) => { return nodes.map((node) => (
node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}> {node.is_dir ? (<> {node.expanded ? : } ) : ()} {node.name} {node.is_dir && ({getAllFilesFlat([node]).length})} {!node.is_dir && (<> {formatFileSize(node.size)} Rename )}
{node.is_dir && node.expanded && node.children &&
{renderLyricTree(node.children, depth + 1)}
}
)); }; const renderCoverTree = (nodes: FileNode[], depth = 0) => { return nodes.map((node) => (
node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}> {node.is_dir ? (<> {node.expanded ? : } ) : ()} {node.name} {node.is_dir && ({getAllFilesFlat([node]).length})} {!node.is_dir && (<> {formatFileSize(node.size)} Rename )}
{node.is_dir && node.expanded && node.children &&
{renderCoverTree(node.children, depth + 1)}
}
)); }; const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length; return (

File Manager

setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
{activeTab === "track" && (

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}

{formatPreset === "custom" && ( setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)} Reset to Default

Preview: {renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac

)}
{activeTab === "track" && (
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
)}
{loading ? (
) : filteredFiles.length === 0 ? (
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
) : (activeTab === "track" ? renderTrackTree(filteredFiles) : activeTab === "lyric" ? renderLyricTree(filteredFiles) : renderCoverTree(filteredFiles))}
Reset to Default? This will reset the rename format to "Title - Artist". Your custom format will be lost. Rename Preview Review the changes before renaming. Files with errors will be skipped.
{previewData.map((item, index) => (
{item.old_name}
{item.error ?
{item.error}
:
→ {item.new_name}
}
))}
{previewOnly ? () : (<> )}
File Metadata {metadataFile.split(/[/\\]/).pop()} {loadingMetadata ? (
) : metadataInfo ? (
Title{metadataInfo.title || "-"}
Artist{metadataInfo.artist || "-"}
Album{metadataInfo.album || "-"}
Album Artist{metadataInfo.album_artist || "-"}
Track{metadataInfo.track_number || "-"}
Disc{metadataInfo.disc_number || "-"}
Year{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}
) : (
No metadata available
)}
Lyrics Preview {lyricsFile.split(/[/\\]/).pop()}
{lyricsTab === "synced" ? (
{renderSyncedLyrics(lyricsContent)}
) : (
            {getPlainLyrics(lyricsContent) || "No lyrics content"}
          
)}
Cover Preview {coverFile.split(/[/\\]/).pop()}
{coverData ? Cover :
Loading...
}
Rename File {manualRenameFile.split(/[/\\]/).pop()}
setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => { if (e.key === "Enter" && !manualRenaming) handleConfirmManualRename(); }}/> {manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}
); }