This commit is contained in:
afkarxyz
2026-04-02 17:41:25 +07:00
parent 99f5e4e8b3
commit 7f12b76fd9
11 changed files with 151 additions and 583 deletions
+2 -7
View File
@@ -15,16 +15,13 @@ import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg"; import KofiSvg from "@/assets/kofi_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg"; import UsdtBarcode from "@/assets/usdt.jpg";
import { langColors } from "@/assets/github-lang-colors"; import { langColors } from "@/assets/github-lang-colors";
const browserExtensionItems = [ const browserExtensionItems = [
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" }, { icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" }, { icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" }, { icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" }, { icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
]; ];
const projectCardClass = "cursor-pointer transition-colors hover:bg-muted/50 dark:hover:bg-accent/50"; const projectCardClass = "cursor-pointer transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
export function AboutPage() { export function AboutPage() {
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects"); const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
const [repoStats, setRepoStats] = useState<Record<string, any>>({}); const [repoStats, setRepoStats] = useState<Record<string, any>>({});
@@ -321,14 +318,12 @@ export function AboutPage() {
<CardHeader> <CardHeader>
<CardTitle>Browser Extensions & Scripts</CardTitle> <CardTitle>Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2 pt-2"> <CardDescription className="flex flex-col gap-2 pt-2">
{browserExtensionItems.map((item) => ( {browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2">
<div key={item.alt} className="flex items-center gap-2">
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/> <img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
<span className="text-[11px] leading-tight text-muted-foreground"> <span className="text-[11px] leading-tight text-muted-foreground">
{item.label} {item.label}
</span> </span>
</div> </div>))}
))}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
-1
View File
@@ -2,7 +2,6 @@ import { Button } from "@/components/ui/button";
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react"; import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { useApiStatus } from "@/hooks/useApiStatus"; import { useApiStatus } from "@/hooks/useApiStatus";
export function ApiStatusTab() { export function ApiStatusTab() {
const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus(); const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus();
return (<div className="space-y-6"> return (<div className="space-y-6">
+58 -317
View File
@@ -13,14 +13,11 @@ import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { GetFileSizes, ListAudioFilesInDir, SaveSpectrumImage, SelectAudioFiles, SelectFolder } from "../../wailsjs/go/main/App"; import { GetFileSizes, ListAudioFilesInDir, SaveSpectrumImage, SelectAudioFiles, SelectFolder } from "../../wailsjs/go/main/App";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps { interface AudioAnalysisPageProps {
onBack?: () => void; onBack?: () => void;
} }
type BatchItemStatus = "pending" | "analyzing" | "success" | "error"; type BatchItemStatus = "pending" | "analyzing" | "success" | "error";
type BatchItemSource = "path" | "browser"; type BatchItemSource = "path" | "browser";
interface BatchAnalysisItem { interface BatchAnalysisItem {
id: string; id: string;
source: BatchItemSource; source: BatchItemSource;
@@ -32,19 +29,16 @@ interface BatchAnalysisItem {
result?: AnalysisResult; result?: AnalysisResult;
file?: File; file?: File;
} }
interface QueueProgressState { interface QueueProgressState {
completed: number; completed: number;
total: number; total: number;
fileName: string; fileName: string;
} }
const EMPTY_PROGRESS_STATE: QueueProgressState = { const EMPTY_PROGRESS_STATE: QueueProgressState = {
completed: 0, completed: 0,
total: 0, total: 0,
fileName: "", fileName: "",
}; };
const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"]; const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"];
const SUPPORTED_AUDIO_ACCEPT = [ const SUPPORTED_AUDIO_ACCEPT = [
".flac", ".flac",
@@ -85,11 +79,9 @@ function fileNameFromPath(filePath: string): string {
const parts = filePath.split(/[/\\]/); const parts = filePath.split(/[/\\]/);
return parts[parts.length - 1] || filePath; return parts[parts.length - 1] || filePath;
} }
function browserFileId(file: File): string { function browserFileId(file: File): string {
return `browser:${file.name}:${file.size}:${file.lastModified}`; return `browser:${file.name}:${file.size}:${file.lastModified}`;
} }
function downloadDataURL(dataUrl: string, fileName: string): void { function downloadDataURL(dataUrl: string, fileName: string): void {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = dataUrl; link.href = dataUrl;
@@ -98,24 +90,20 @@ function downloadDataURL(dataUrl: string, fileName: string): void {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} }
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
if (bytes <= 0) { if (bytes <= 0) {
return "0 B"; return "0 B";
} }
const k = 1024; const k = 1024;
const sizes = ["B", "KB", "MB", "GB"]; const sizes = ["B", "KB", "MB", "GB"];
const index = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); const index = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
return `${parseFloat((bytes / Math.pow(k, index)).toFixed(1))} ${sizes[index]}`; return `${parseFloat((bytes / Math.pow(k, index)).toFixed(1))} ${sizes[index]}`;
} }
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`; return `${mins}:${secs.toString().padStart(2, "0")}`;
} }
function itemMetaLine(item: BatchAnalysisItem): string { function itemMetaLine(item: BatchAnalysisItem): string {
if (item.result) { if (item.result) {
const parts = [ const parts = [
@@ -123,14 +111,11 @@ function itemMetaLine(item: BatchAnalysisItem): string {
`${(item.result.sample_rate / 1000).toFixed(1)} kHz`, `${(item.result.sample_rate / 1000).toFixed(1)} kHz`,
formatDuration(item.result.duration), formatDuration(item.result.duration),
]; ];
if (typeof item.result.bitrate_kbps === "number" && item.result.bitrate_kbps > 0) { if (typeof item.result.bitrate_kbps === "number" && item.result.bitrate_kbps > 0) {
parts.push(`${item.result.bitrate_kbps} kbps`); parts.push(`${item.result.bitrate_kbps} kbps`);
} }
return parts.join(" • "); return parts.join(" • ");
} }
switch (item.status) { switch (item.status) {
case "analyzing": case "analyzing":
return "Analyzing audio quality..."; return "Analyzing audio quality...";
@@ -141,7 +126,6 @@ function itemMetaLine(item: BatchAnalysisItem): string {
return "Waiting to be analyzed"; return "Waiting to be analyzed";
} }
} }
function statusIcon(status: BatchItemStatus) { function statusIcon(status: BatchItemStatus) {
switch (status) { switch (status) {
case "analyzing": case "analyzing":
@@ -156,19 +140,7 @@ function statusIcon(status: BatchItemStatus) {
} }
} }
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { const { analysisProgress, spectrumLoading, spectrumProgress, analyzeFile, analyzeFilePath, cancelAnalysis, loadStoredAnalysis, clearStoredAnalysis, reAnalyzeSpectrum, clearResult, } = useAudioAnalysis();
analysisProgress,
spectrumLoading,
spectrumProgress,
analyzeFile,
analyzeFilePath,
cancelAnalysis,
loadStoredAnalysis,
clearStoredAnalysis,
reAnalyzeSpectrum,
clearResult,
} = useAudioAnalysis();
const [items, setItems] = useState<BatchAnalysisItem[]>([]); const [items, setItems] = useState<BatchAnalysisItem[]>([]);
const [activeItemId, setActiveItemId] = useState<string | null>(null); const [activeItemId, setActiveItemId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -177,53 +149,43 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const [isBatchRunning, setIsBatchRunning] = useState(false); const [isBatchRunning, setIsBatchRunning] = useState(false);
const [batchProgress, setBatchProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE); const [batchProgress, setBatchProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE);
const [exportProgress, setExportProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE); const [exportProgress, setExportProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const spectrumRef = useRef<SpectrumVisualizationHandle>(null); const spectrumRef = useRef<SpectrumVisualizationHandle>(null);
const batchRunIdRef = useRef(0); const batchRunIdRef = useRef(0);
const itemsRef = useRef(items); const itemsRef = useRef(items);
const activeItemIdRef = useRef<string | null>(activeItemId); const activeItemIdRef = useRef<string | null>(activeItemId);
useEffect(() => { useEffect(() => {
itemsRef.current = items; itemsRef.current = items;
}, [items]); }, [items]);
useEffect(() => { useEffect(() => {
activeItemIdRef.current = activeItemId; activeItemIdRef.current = activeItemId;
}, [activeItemId]); }, [activeItemId]);
const setActiveSelection = useCallback((nextId: string | null) => { const setActiveSelection = useCallback((nextId: string | null) => {
activeItemIdRef.current = nextId; activeItemIdRef.current = nextId;
setActiveItemId(nextId); setActiveItemId(nextId);
}, []); }, []);
const activeItem = items.find((item) => item.id === activeItemId) ?? null; const activeItem = items.find((item) => item.id === activeItemId) ?? null;
const successItems = items.filter((item) => item.status === "success" && item.result?.spectrum); const successItems = items.filter((item) => item.status === "success" && item.result?.spectrum);
const pendingItems = items.filter((item) => item.status === "pending"); const pendingItems = items.filter((item) => item.status === "pending");
const isSingleMode = items.length === 1; const isSingleMode = items.length === 1;
const isBatchMode = items.length > 1; const isBatchMode = items.length > 1;
const canResumeBatch = isBatchMode && !isBatchRunning && pendingItems.length > 0; const canResumeBatch = isBatchMode && !isBatchRunning && pendingItems.length > 0;
const batchPercent = batchProgress.total > 0 const batchPercent = batchProgress.total > 0
? Math.round(Math.max(0, Math.min(100, ((batchProgress.completed + (isBatchRunning ? analysisProgress.percent / 100 : 0)) / batchProgress.total) * 100))) ? Math.round(Math.max(0, Math.min(100, ((batchProgress.completed + (isBatchRunning ? analysisProgress.percent / 100 : 0)) / batchProgress.total) * 100)))
: 0; : 0;
const exportPercent = exportProgress.total > 0 const exportPercent = exportProgress.total > 0
? Math.round(Math.max(0, Math.min(100, (exportProgress.completed / exportProgress.total) * 100))) ? Math.round(Math.max(0, Math.min(100, (exportProgress.completed / exportProgress.total) * 100)))
: 0; : 0;
useEffect(() => { useEffect(() => {
if (!activeItem?.result) { if (!activeItem?.result) {
return; return;
} }
loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path); loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path);
}, [activeItem, loadStoredAnalysis]); }, [activeItem, loadStoredAnalysis]);
const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => { const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => {
if (entries.length === 0) { if (entries.length === 0) {
return; return;
} }
const runId = batchRunIdRef.current + 1; const runId = batchRunIdRef.current + 1;
batchRunIdRef.current = runId; batchRunIdRef.current = runId;
setIsBatchRunning(true); setIsBatchRunning(true);
@@ -232,27 +194,22 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
total: entries.length, total: entries.length,
fileName: entries[0]?.name ?? "", fileName: entries[0]?.name ?? "",
}); });
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
try { try {
for (let index = 0; index < entries.length; index++) { for (let index = 0; index < entries.length; index++) {
if (batchRunIdRef.current !== runId) { if (batchRunIdRef.current !== runId) {
return; return;
} }
const entry = entries[index]; const entry = entries[index];
setBatchProgress({ setBatchProgress({
completed: index, completed: index,
total: entries.length, total: entries.length,
fileName: entry.name, fileName: entry.name,
}); });
setItems((prev) => prev.map((item) => item.id === entry.id setItems((prev) => prev.map((item) => item.id === entry.id
? { ...item, status: "analyzing", error: undefined } ? { ...item, status: "analyzing", error: undefined }
: item)); : item));
const outcome = entry.source === "browser" && entry.file const outcome = entry.source === "browser" && entry.file
? await analyzeFile(entry.file, { ? await analyzeFile(entry.file, {
analysisKey: entry.id, analysisKey: entry.id,
@@ -264,15 +221,12 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
displayPath: entry.path, displayPath: entry.path,
suppressToast: true, suppressToast: true,
}); });
if (batchRunIdRef.current !== runId) { if (batchRunIdRef.current !== runId) {
return; return;
} }
if (outcome.cancelled) { if (outcome.cancelled) {
return; return;
} }
if (outcome.result) { if (outcome.result) {
const analysisResult = outcome.result; const analysisResult = outcome.result;
successCount++; successCount++;
@@ -285,7 +239,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
size: analysisResult.file_size || item.size, size: analysisResult.file_size || item.size,
} }
: item)); : item));
const hasSelectedSuccess = itemsRef.current.some((item) => item.id === activeItemIdRef.current && item.status === "success" && item.result); const hasSelectedSuccess = itemsRef.current.some((item) => item.id === activeItemIdRef.current && item.status === "success" && item.result);
if (!hasSelectedSuccess) { if (!hasSelectedSuccess) {
setActiveSelection(entry.id); setActiveSelection(entry.id);
@@ -300,20 +253,17 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
error: outcome.error || "Analysis failed", error: outcome.error || "Analysis failed",
} }
: item)); : item));
if (!activeItemIdRef.current) { if (!activeItemIdRef.current) {
setActiveSelection(entry.id); setActiveSelection(entry.id);
} }
} }
} }
if (batchRunIdRef.current === runId) { if (batchRunIdRef.current === runId) {
setBatchProgress({ setBatchProgress({
completed: entries.length, completed: entries.length,
total: entries.length, total: entries.length,
fileName: "", fileName: "",
}); });
if (successCount > 0) { if (successCount > 0) {
toast.success("Batch Analysis Complete", { toast.success("Batch Analysis Complete", {
description: `Successfully analyzed ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, description: `Successfully analyzed ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
@@ -332,47 +282,38 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
} }
} }
}, [analyzeFile, analyzeFilePath, setActiveSelection]); }, [analyzeFile, analyzeFilePath, setActiveSelection]);
const ensureIdleQueue = useCallback(() => { const ensureIdleQueue = useCallback(() => {
if (!isBatchRunning) { if (!isBatchRunning) {
return true; return true;
} }
toast.info("Analysis in progress", { toast.info("Analysis in progress", {
description: "Please wait for the current batch to finish or clear it first.", description: "Please wait for the current batch to finish or clear it first.",
}); });
return false; return false;
}, [isBatchRunning]); }, [isBatchRunning]);
const addPathItems = useCallback(async (paths: string[]) => { const addPathItems = useCallback(async (paths: string[]) => {
if (!ensureIdleQueue()) { if (!ensureIdleQueue()) {
return; return;
} }
const uniquePaths = Array.from(new Set(paths.filter(Boolean))); const uniquePaths = Array.from(new Set(paths.filter(Boolean)));
const invalidCount = uniquePaths.filter((path) => !isSupportedAudioPath(path)).length; const invalidCount = uniquePaths.filter((path) => !isSupportedAudioPath(path)).length;
const validPaths = uniquePaths.filter(isSupportedAudioPath); const validPaths = uniquePaths.filter(isSupportedAudioPath);
if (invalidCount > 0) { if (invalidCount > 0) {
toast.error("Unsupported format", { toast.error("Unsupported format", {
description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`,
}); });
} }
if (validPaths.length === 0) { if (validPaths.length === 0) {
return; return;
} }
const existingIds = new Set(itemsRef.current.map((item) => item.id)); const existingIds = new Set(itemsRef.current.map((item) => item.id));
const newPaths = validPaths.filter((path) => !existingIds.has(path)); const newPaths = validPaths.filter((path) => !existingIds.has(path));
if (newPaths.length === 0) { if (newPaths.length === 0) {
toast.info("No new files added", { toast.info("No new files added", {
description: "All selected files were already in the batch queue.", description: "All selected files were already in the batch queue.",
}); });
return; return;
} }
const fileSizes = await GetFileSizes(newPaths); const fileSizes = await GetFileSizes(newPaths);
const newItems = newPaths.map((path) => ({ const newItems = newPaths.map((path) => ({
id: path, id: path,
@@ -382,39 +323,31 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
size: fileSizes[path] || 0, size: fileSizes[path] || 0,
status: "pending" as const, status: "pending" as const,
})); }));
if (validPaths.length !== newPaths.length) { if (validPaths.length !== newPaths.length) {
toast.info("Some files skipped", { toast.info("Some files skipped", {
description: `${validPaths.length - newPaths.length} file(s) were already queued.`, description: `${validPaths.length - newPaths.length} file(s) were already queued.`,
}); });
} }
setItems((prev) => [...prev, ...newItems]); setItems((prev) => [...prev, ...newItems]);
if (!activeItemIdRef.current) { if (!activeItemIdRef.current) {
setActiveSelection(newItems[0]?.id ?? null); setActiveSelection(newItems[0]?.id ?? null);
} }
void runBatchAnalysis(newItems); void runBatchAnalysis(newItems);
}, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]);
const addBrowserFiles = useCallback(async (files: File[]) => { const addBrowserFiles = useCallback(async (files: File[]) => {
if (!ensureIdleQueue()) { if (!ensureIdleQueue()) {
return; return;
} }
const validFiles = files.filter(isSupportedAudioFile); const validFiles = files.filter(isSupportedAudioFile);
const invalidCount = files.length - validFiles.length; const invalidCount = files.length - validFiles.length;
if (invalidCount > 0) { if (invalidCount > 0) {
toast.error("Unsupported format", { toast.error("Unsupported format", {
description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`,
}); });
} }
if (validFiles.length === 0) { if (validFiles.length === 0) {
return; return;
} }
const existingIds = new Set(itemsRef.current.map((item) => item.id)); const existingIds = new Set(itemsRef.current.map((item) => item.id));
const newItems = validFiles const newItems = validFiles
.map((file) => ({ .map((file) => ({
@@ -427,33 +360,27 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
file, file,
})) }))
.filter((item) => !existingIds.has(item.id)); .filter((item) => !existingIds.has(item.id));
if (newItems.length === 0) { if (newItems.length === 0) {
toast.info("No new files added", { toast.info("No new files added", {
description: "All selected files were already in the batch queue.", description: "All selected files were already in the batch queue.",
}); });
return; return;
} }
if (validFiles.length !== newItems.length) { if (validFiles.length !== newItems.length) {
toast.info("Some files skipped", { toast.info("Some files skipped", {
description: `${validFiles.length - newItems.length} file(s) were already queued.`, description: `${validFiles.length - newItems.length} file(s) were already queued.`,
}); });
} }
setItems((prev) => [...prev, ...newItems]); setItems((prev) => [...prev, ...newItems]);
if (!activeItemIdRef.current) { if (!activeItemIdRef.current) {
setActiveSelection(newItems[0]?.id ?? null); setActiveSelection(newItems[0]?.id ?? null);
} }
void runBatchAnalysis(newItems); void runBatchAnalysis(newItems);
}, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]);
const handleSelectFiles = useCallback(async () => { const handleSelectFiles = useCallback(async () => {
if (!ensureIdleQueue()) { if (!ensureIdleQueue()) {
return; return;
} }
try { try {
const selectedPaths = await SelectAudioFiles(); const selectedPaths = await SelectAudioFiles();
if (selectedPaths && selectedPaths.length > 0) { if (selectedPaths && selectedPaths.length > 0) {
@@ -466,18 +393,15 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
return; return;
} }
}, [addPathItems, ensureIdleQueue]); }, [addPathItems, ensureIdleQueue]);
const handleSelectFolder = useCallback(async () => { const handleSelectFolder = useCallback(async () => {
if (!ensureIdleQueue()) { if (!ensureIdleQueue()) {
return; return;
} }
try { try {
const selectedFolder = await SelectFolder(""); const selectedFolder = await SelectFolder("");
if (!selectedFolder) { if (!selectedFolder) {
return; return;
} }
const folderFiles = await ListAudioFilesInDir(selectedFolder); const folderFiles = await ListAudioFilesInDir(selectedFolder);
if (!folderFiles || folderFiles.length === 0) { if (!folderFiles || folderFiles.length === 0) {
toast.info("No audio files found", { toast.info("No audio files found", {
@@ -485,7 +409,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
}); });
return; return;
} }
await addPathItems(folderFiles.map((file) => file.path)); await addPathItems(folderFiles.map((file) => file.path));
} }
catch (err) { catch (err) {
@@ -494,59 +417,46 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
}); });
} }
}, [addPathItems, ensureIdleQueue]); }, [addPathItems, ensureIdleQueue]);
const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => { const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []); const files = Array.from(event.target.files ?? []);
event.target.value = ""; event.target.value = "";
if (files.length === 0) { if (files.length === 0) {
return; return;
} }
await addBrowserFiles(files); await addBrowserFiles(files);
}, [addBrowserFiles]); }, [addBrowserFiles]);
const handleHtmlDrop = useCallback(async (event: DragEvent<HTMLDivElement>) => { const handleHtmlDrop = useCallback(async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
setIsDragging(false); setIsDragging(false);
const files = Array.from(event.dataTransfer.files ?? []); const files = Array.from(event.dataTransfer.files ?? []);
if (files.length === 0) { if (files.length === 0) {
return; return;
} }
await addBrowserFiles(files); await addBrowserFiles(files);
}, [addBrowserFiles]); }, [addBrowserFiles]);
useEffect(() => { useEffect(() => {
OnFileDrop((_x, _y, paths) => { OnFileDrop((_x, _y, paths) => {
setIsDragging(false); setIsDragging(false);
if (!paths || paths.length === 0) { if (!paths || paths.length === 0) {
return; return;
} }
void addPathItems(paths); void addPathItems(paths);
}, true); }, true);
return () => { return () => {
OnFileDropOff(); OnFileDropOff();
}; };
}, [addPathItems]); }, [addPathItems]);
const handleSelectItem = useCallback((itemId: string) => { const handleSelectItem = useCallback((itemId: string) => {
setActiveSelection(itemId); setActiveSelection(itemId);
}, [setActiveSelection]); }, [setActiveSelection]);
const handleRemoveItem = useCallback((itemId: string) => { const handleRemoveItem = useCallback((itemId: string) => {
if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) {
return; return;
} }
clearStoredAnalysis(itemId); clearStoredAnalysis(itemId);
const nextItems = itemsRef.current.filter((item) => item.id !== itemId); const nextItems = itemsRef.current.filter((item) => item.id !== itemId);
itemsRef.current = nextItems; itemsRef.current = nextItems;
setItems(nextItems); setItems(nextItems);
if (activeItemIdRef.current === itemId) { if (activeItemIdRef.current === itemId) {
const nextActive = nextItems.find((item) => item.status === "success" && item.result) ?? nextItems[0] ?? null; const nextActive = nextItems.find((item) => item.status === "success" && item.result) ?? nextItems[0] ?? null;
setActiveSelection(nextActive?.id ?? null); setActiveSelection(nextActive?.id ?? null);
@@ -555,12 +465,10 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
} }
} }
}, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]); }, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]);
const handleClearAll = useCallback(() => { const handleClearAll = useCallback(() => {
if (isExportingBatch || isExportingSelected) { if (isExportingBatch || isExportingSelected) {
return; return;
} }
batchRunIdRef.current += 1; batchRunIdRef.current += 1;
itemsRef.current = []; itemsRef.current = [];
setItems([]); setItems([]);
@@ -572,12 +480,10 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
setExportProgress(EMPTY_PROGRESS_STATE); setExportProgress(EMPTY_PROGRESS_STATE);
setIsDragging(false); setIsDragging(false);
}, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]); }, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]);
const handleStopBatch = useCallback(() => { const handleStopBatch = useCallback(() => {
if (!isBatchRunning) { if (!isBatchRunning) {
return; return;
} }
batchRunIdRef.current += 1; batchRunIdRef.current += 1;
cancelAnalysis(); cancelAnalysis();
setIsBatchRunning(false); setIsBatchRunning(false);
@@ -592,25 +498,20 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
description: "Click Analyze to continue the remaining files.", description: "Click Analyze to continue the remaining files.",
}); });
}, [cancelAnalysis, isBatchRunning]); }, [cancelAnalysis, isBatchRunning]);
const handleAnalyzePending = useCallback(() => { const handleAnalyzePending = useCallback(() => {
if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) {
return; return;
} }
const nextPendingItems = itemsRef.current.filter((item) => item.status === "pending"); const nextPendingItems = itemsRef.current.filter((item) => item.status === "pending");
if (nextPendingItems.length === 0) { if (nextPendingItems.length === 0) {
return; return;
} }
void runBatchAnalysis(nextPendingItems); void runBatchAnalysis(nextPendingItems);
}, [isBatchRunning, isExportingBatch, isExportingSelected, runBatchAnalysis, spectrumLoading]); }, [isBatchRunning, isExportingBatch, isExportingSelected, runBatchAnalysis, spectrumLoading]);
const handleExportSelected = useCallback(async () => { const handleExportSelected = useCallback(async () => {
if (!activeItem?.result?.spectrum || !spectrumRef.current) { if (!activeItem?.result?.spectrum || !spectrumRef.current) {
return; return;
} }
const dataUrl = spectrumRef.current.getCanvasDataURL(); const dataUrl = spectrumRef.current.getCanvasDataURL();
if (!dataUrl) { if (!dataUrl) {
toast.error("Export Failed", { toast.error("Export Failed", {
@@ -618,9 +519,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
}); });
return; return;
} }
setIsExportingSelected(true); setIsExportingSelected(true);
try { try {
if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) { if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) {
const outPath = await SaveSpectrumImage(activeItem.path, dataUrl); const outPath = await SaveSpectrumImage(activeItem.path, dataUrl);
@@ -629,7 +528,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
}); });
return; return;
} }
const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram"; const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram";
downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); downloadDataURL(dataUrl, `${baseName}_spectrogram.png`);
toast.success("PNG Exported", { toast.success("PNG Exported", {
@@ -645,17 +543,14 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
setIsExportingSelected(false); setIsExportingSelected(false);
} }
}, [activeItem]); }, [activeItem]);
const handleBatchExport = useCallback(async () => { const handleBatchExport = useCallback(async () => {
const exportableItems = itemsRef.current.filter((item) => item.status === "success" && item.result?.spectrum); const exportableItems = itemsRef.current.filter((item) => item.status === "success" && item.result?.spectrum);
if (exportableItems.length === 0) { if (exportableItems.length === 0) {
toast.error("Nothing to export", { toast.error("Nothing to export", {
description: "Analyze at least one file successfully before exporting PNGs.", description: "Analyze at least one file successfully before exporting PNGs.",
}); });
return; return;
} }
const preferences = loadAudioAnalysisPreferences(); const preferences = loadAudioAnalysisPreferences();
setIsExportingBatch(true); setIsExportingBatch(true);
setExportProgress({ setExportProgress({
@@ -663,26 +558,21 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
total: exportableItems.length, total: exportableItems.length,
fileName: exportableItems[0]?.name ?? "", fileName: exportableItems[0]?.name ?? "",
}); });
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
try { try {
for (let index = 0; index < exportableItems.length; index++) { for (let index = 0; index < exportableItems.length; index++) {
const item = exportableItems[index]; const item = exportableItems[index];
const result = item.result; const result = item.result;
if (!result?.spectrum) { if (!result?.spectrum) {
failCount++; failCount++;
continue; continue;
} }
setExportProgress({ setExportProgress({
completed: index, completed: index,
total: exportableItems.length, total: exportableItems.length,
fileName: item.name, fileName: item.name,
}); });
try { try {
const dataUrl = await createSpectrogramDataURL({ const dataUrl = await createSpectrogramDataURL({
spectrumData: result.spectrum, spectrumData: result.spectrum,
@@ -692,7 +582,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
colorScheme: preferences.colorScheme, colorScheme: preferences.colorScheme,
fileName: item.name, fileName: item.name,
}); });
if (item.source === "path" && isAbsolutePath(item.path)) { if (item.source === "path" && isAbsolutePath(item.path)) {
await SaveSpectrumImage(item.path, dataUrl); await SaveSpectrumImage(item.path, dataUrl);
} }
@@ -700,22 +589,18 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const baseName = item.name.replace(/\.[^/.]+$/, "") || "spectrogram"; const baseName = item.name.replace(/\.[^/.]+$/, "") || "spectrogram";
downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); downloadDataURL(dataUrl, `${baseName}_spectrogram.png`);
} }
successCount++; successCount++;
} }
catch { catch {
failCount++; failCount++;
} }
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
} }
setExportProgress({ setExportProgress({
completed: exportableItems.length, completed: exportableItems.length,
total: exportableItems.length, total: exportableItems.length,
fileName: "", fileName: "",
}); });
if (successCount > 0) { if (successCount > 0) {
toast.success("Batch PNG Export Complete", { toast.success("Batch PNG Export Complete", {
description: `Exported ${successCount} spectrogram PNG file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, description: `Exported ${successCount} spectrogram PNG file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
@@ -731,17 +616,14 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
setIsExportingBatch(false); setIsExportingBatch(false);
} }
}, []); }, []);
const handleReAnalyzeSelectedSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { const handleReAnalyzeSelectedSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
if (!activeItem?.result) { if (!activeItem?.result) {
return; return;
} }
const nextResult = await reAnalyzeSpectrum(fftSize, windowFunction); const nextResult = await reAnalyzeSpectrum(fftSize, windowFunction);
if (!nextResult) { if (!nextResult) {
return; return;
} }
setItems((prev) => prev.map((item) => item.id === activeItem.id setItems((prev) => prev.map((item) => item.id === activeItem.id
? { ? {
...item, ...item,
@@ -751,88 +633,43 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
} }
: item)); : item));
}, [activeItem, reAnalyzeSpectrum]); }, [activeItem, reAnalyzeSpectrum]);
const batchDetailContent = !activeItem ? (<Card>
const batchDetailContent = !activeItem ? (
<Card>
<CardContent className="flex min-h-[320px] items-center justify-center px-6 py-10"> <CardContent className="flex min-h-[320px] items-center justify-center px-6 py-10">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Select a file from the batch queue to inspect its analysis result. Select a file from the batch queue to inspect its analysis result.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>) : activeItem.status !== "success" || !activeItem.result ? (<Card>
) : activeItem.status !== "success" || !activeItem.result ? (
<Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">{activeItem.name}</CardTitle> <CardTitle className="text-base">{activeItem.name}</CardTitle>
<p className="break-all font-mono text-sm text-muted-foreground">{activeItem.path}</p> <p className="break-all font-mono text-sm text-muted-foreground">{activeItem.path}</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{activeItem.status === "analyzing" && ( {activeItem.status === "analyzing" && (<div className="space-y-3">
<div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Spinner /> <Spinner />
<span className="text-sm text-muted-foreground">Analyzing audio quality...</span> <span className="text-sm text-muted-foreground">Analyzing audio quality...</span>
</div> </div>
<Progress value={analysisProgress.percent} className="h-2 w-full"/> <Progress value={analysisProgress.percent} className="h-2 w-full"/>
<p className="text-xs text-muted-foreground">{analysisProgress.message}</p> <p className="text-xs text-muted-foreground">{analysisProgress.message}</p>
</div> </div>)}
)} {activeItem.status === "pending" && (<p className="text-sm text-muted-foreground">
{activeItem.status === "pending" && (
<p className="text-sm text-muted-foreground">
This file is queued and waiting for batch analysis to start. This file is queued and waiting for batch analysis to start.
</p> </p>)}
)} {activeItem.status === "error" && (<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{activeItem.status === "error" && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{activeItem.error || "Analysis failed"} {activeItem.error || "Analysis failed"}
</div> </div>)}
)}
</CardContent> </CardContent>
</Card> </Card>) : (<div className="space-y-4">
) : ( <AudioAnalysis result={activeItem.result} analyzing={false} showAnalyzeButton={false} filePath={activeItem.path}/>
<div className="space-y-4">
<AudioAnalysis
result={activeItem.result}
analyzing={false}
showAnalyzeButton={false}
filePath={activeItem.path}
/>
<SpectrumVisualization <SpectrumVisualization ref={spectrumRef} sampleRate={activeItem.result.sample_rate} duration={activeItem.result.duration} spectrumData={activeItem.result.spectrum} fileName={activeItem.name} onReAnalyze={handleReAnalyzeSelectedSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
ref={spectrumRef} </div>);
sampleRate={activeItem.result.sample_rate} const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? (<div className="mx-auto w-full max-w-6xl space-y-4">
duration={activeItem.result.duration} <AudioAnalysis result={activeItem.result} analyzing={false} showAnalyzeButton={false} filePath={activeItem.path}/>
spectrumData={activeItem.result.spectrum}
fileName={activeItem.name}
onReAnalyze={handleReAnalyzeSelectedSpectrum}
isAnalyzingSpectrum={spectrumLoading}
spectrumProgress={spectrumProgress}
/>
</div>
);
const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? ( <SpectrumVisualization ref={spectrumRef} sampleRate={activeItem.result.sample_rate} duration={activeItem.result.duration} spectrumData={activeItem.result.spectrum} fileName={activeItem.name} onReAnalyze={handleReAnalyzeSelectedSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
<div className="mx-auto w-full max-w-6xl space-y-4"> </div>) : activeItem.status === "analyzing" || activeItem.status === "pending" ? (<div className="flex h-[400px] items-center justify-center">
<AudioAnalysis
result={activeItem.result}
analyzing={false}
showAnalyzeButton={false}
filePath={activeItem.path}
/>
<SpectrumVisualization
ref={spectrumRef}
sampleRate={activeItem.result.sample_rate}
duration={activeItem.result.duration}
spectrumData={activeItem.result.spectrum}
fileName={activeItem.name}
onReAnalyze={handleReAnalyzeSelectedSpectrum}
isAnalyzingSpectrum={spectrumLoading}
spectrumProgress={spectrumProgress}
/>
</div>
) : activeItem.status === "analyzing" || activeItem.status === "pending" ? (
<div className="flex h-[400px] items-center justify-center">
<div className="w-full max-w-md space-y-2"> <div className="w-full max-w-md space-y-2">
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{activeItem.status === "pending" ? "Preparing..." : "Processing..."}</span> <span>{activeItem.status === "pending" ? "Preparing..." : "Processing..."}</span>
@@ -841,64 +678,33 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
<Progress value={analysisProgress.percent} className="h-2 w-full"/> <Progress value={analysisProgress.percent} className="h-2 w-full"/>
<p className="text-center text-xs text-muted-foreground">{analysisProgress.message}</p> <p className="text-center text-xs text-muted-foreground">{analysisProgress.message}</p>
</div> </div>
</div> </div>) : (<div className="flex h-[400px] items-center justify-center">
) : (
<div className="flex h-[400px] items-center justify-center">
<div className="w-full max-w-md rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"> <div className="w-full max-w-md rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{activeItem.error || "Analysis failed"} {activeItem.error || "Analysis failed"}
</div> </div>
</div> </div>);
);
const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result; const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result;
return (<div className="space-y-6">
return ( <input ref={fileInputRef} type="file" multiple accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
<div className="space-y-6">
<input
ref={fileInputRef}
type="file"
multiple
accept={SUPPORTED_AUDIO_ACCEPT}
className="hidden"
onChange={handleInputChange}
/>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{onBack && ( {onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5"/> <ArrowLeft className="h-5 w-5"/>
</Button> </Button>)}
)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1> <h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{isBatchMode && isBatchRunning && ( {isBatchMode && isBatchRunning && (<Button onClick={handleStopBatch} variant="destructive" size="sm" disabled={isExportingBatch || isExportingSelected} className="gap-1.5">
<Button
onClick={handleStopBatch}
variant="destructive"
size="sm"
disabled={isExportingBatch || isExportingSelected}
className="gap-1.5"
>
<StopCircle className="h-4 w-4"/> <StopCircle className="h-4 w-4"/>
Stop Stop
</Button> </Button>)}
)} {canResumeBatch && (<Button onClick={handleAnalyzePending} variant="outline" size="sm" disabled={isExportingBatch || isExportingSelected || spectrumLoading}>
{canResumeBatch && (
<Button
onClick={handleAnalyzePending}
variant="outline"
size="sm"
disabled={isExportingBatch || isExportingSelected || spectrumLoading}
>
<Play className="h-4 w-4"/> <Play className="h-4 w-4"/>
Analyze Analyze
</Button> </Button>)}
)} {isBatchMode && (<DropdownMenu>
{isBatchMode && (
<DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isBatchRunning || isExportingBatch || isExportingSelected}> <Button variant="outline" size="sm" disabled={isBatchRunning || isExportingBatch || isExportingSelected}>
<Upload className="h-4 w-4 mr-1"/> <Upload className="h-4 w-4 mr-1"/>
@@ -916,27 +722,14 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
Add Folder Add Folder
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>)}
)} {showSingleModeActions && (<Button onClick={handleExportSelected} variant="outline" size="sm" disabled={isExportingSelected || spectrumLoading}>
{showSingleModeActions && (
<Button
onClick={handleExportSelected}
variant="outline"
size="sm"
disabled={isExportingSelected || spectrumLoading}
>
<Download className="h-4 w-4 mr-1"/> <Download className="h-4 w-4 mr-1"/>
{isExportingSelected ? "Exporting..." : "Export PNG"} {isExportingSelected ? "Exporting..." : "Export PNG"}
</Button> </Button>)}
)} {isBatchMode && (<DropdownMenu>
{isBatchMode && (
<DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="outline" size="sm" disabled={successItems.length === 0 || isExportingBatch || isExportingSelected || isBatchRunning || spectrumLoading}>
variant="outline"
size="sm"
disabled={successItems.length === 0 || isExportingBatch || isExportingSelected || isBatchRunning || spectrumLoading}
>
<Download className="h-4 w-4 mr-1"/> <Download className="h-4 w-4 mr-1"/>
{isExportingBatch ? "Exporting..." : isExportingSelected ? "Exporting..." : "Export"} {isExportingBatch ? "Exporting..." : isExportingSelected ? "Exporting..." : "Export"}
<ChevronDown className="ml-1 h-4 w-4"/> <ChevronDown className="ml-1 h-4 w-4"/>
@@ -952,49 +745,25 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
Export All PNG Export All PNG
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>)}
)} {showSingleModeActions && (<Button onClick={handleClearAll} variant="outline" size="sm" disabled={isExportingSelected}>
{showSingleModeActions && (
<Button
onClick={handleClearAll}
variant="outline"
size="sm"
disabled={isExportingSelected}
>
<Trash2 className="h-4 w-4 mr-1"/> <Trash2 className="h-4 w-4 mr-1"/>
Clear Clear
</Button> </Button>)}
)} {isBatchMode && (<Button onClick={handleClearAll} variant="outline" size="sm" disabled={isExportingBatch || isExportingSelected}>
{isBatchMode && (
<Button
onClick={handleClearAll}
variant="outline"
size="sm"
disabled={isExportingBatch || isExportingSelected}
>
<Trash2 className="h-4 w-4 mr-1"/> <Trash2 className="h-4 w-4 mr-1"/>
Clear Clear
</Button> </Button>)}
)}
</div> </div>
</div> </div>
{items.length === 0 && ( {items.length === 0 && (<div className={`flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed transition-all ${isDragging ? "border-primary bg-primary/10" : "border-muted-foreground/30"}`} onDragOver={(event) => {
<div
className={`flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed transition-all ${
isDragging ? "border-primary bg-primary/10" : "border-muted-foreground/30"
}`}
onDragOver={(event) => {
event.preventDefault(); event.preventDefault();
setIsDragging(true); setIsDragging(true);
}} }} onDragLeave={(event) => {
onDragLeave={(event) => {
event.preventDefault(); event.preventDefault();
setIsDragging(false); setIsDragging(false);
}} }} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
onDrop={handleHtmlDrop}
style={{ "--wails-drop-target": "drop" } as CSSProperties}
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted"> <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"/> <Upload className="h-8 w-8 text-primary"/>
</div> </div>
@@ -1016,20 +785,15 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
<p className="text-xs text-muted-foreground mt-4 text-center"> <p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3, M4A, AAC Supported formats: FLAC, MP3, M4A, AAC
</p> </p>
</div> </div>)}
)}
{isSingleMode && ( {isSingleMode && (<div className="space-y-4">
<div className="space-y-4">
{singleModeContent} {singleModeContent}
</div> </div>)}
)}
{isBatchMode && ( {isBatchMode && (<div className="grid gap-4 xl:grid-cols-[360px,minmax(0,1fr)]">
<div className="grid gap-4 xl:grid-cols-[360px,minmax(0,1fr)]">
<div className="space-y-3"> <div className="space-y-3">
{(isBatchRunning || isExportingBatch) && ( {(isBatchRunning || isExportingBatch) && (<Card className="gap-2 py-4">
<Card className="gap-2 py-4">
<CardHeader className="px-4 pb-0"> <CardHeader className="px-4 pb-0">
<CardTitle className="text-sm"> <CardTitle className="text-sm">
{isExportingBatch ? "Batch PNG Export" : "Batch Analysis"} {isExportingBatch ? "Batch PNG Export" : "Batch Analysis"}
@@ -1049,15 +813,12 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
</span> </span>
</div> </div>
<Progress value={isExportingBatch ? exportPercent : batchPercent} className="h-1.5 w-full"/> <Progress value={isExportingBatch ? exportPercent : batchPercent} className="h-1.5 w-full"/>
{!isExportingBatch && ( {!isExportingBatch && (<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{analysisProgress.message}</span> <span>{analysisProgress.message}</span>
<span className="tabular-nums">{analysisProgress.percent}%</span> <span className="tabular-nums">{analysisProgress.percent}%</span>
</div> </div>)}
)}
</CardContent> </CardContent>
</Card> </Card>)}
)}
<Card className="gap-2 overflow-hidden py-4"> <Card className="gap-2 overflow-hidden py-4">
<CardHeader className="px-4 pb-0"> <CardHeader className="px-4 pb-0">
@@ -1073,25 +834,16 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
{items.map((item) => { {items.map((item) => {
const isActive = item.id === activeItemId; const isActive = item.id === activeItemId;
const isSelectable = item.status !== "pending"; const isSelectable = item.status !== "pending";
return ( return (<div key={item.id} role={isSelectable ? "button" : undefined} tabIndex={isSelectable ? 0 : -1} className={`flex w-full items-start gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors ${isActive
<div
key={item.id}
role={isSelectable ? "button" : undefined}
tabIndex={isSelectable ? 0 : -1}
className={`flex w-full items-start gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors ${
isActive
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: isSelectable : isSelectable
? "border-border hover:border-primary/40" ? "border-border hover:border-primary/40"
: "border-border" : "border-border"}`} onClick={() => {
}`}
onClick={() => {
if (!isSelectable) { if (!isSelectable) {
return; return;
} }
handleSelectItem(item.id); handleSelectItem(item.id);
}} }} onKeyDown={(event) => {
onKeyDown={(event) => {
if (!isSelectable) { if (!isSelectable) {
return; return;
} }
@@ -1099,8 +851,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
event.preventDefault(); event.preventDefault();
handleSelectItem(item.id); handleSelectItem(item.id);
} }
}} }}>
>
<div className="mt-0.5 shrink-0">{statusIcon(item.status)}</div> <div className="mt-0.5 shrink-0">{statusIcon(item.status)}</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.name}</p> <p className="truncate text-sm font-medium">{item.name}</p>
@@ -1112,21 +863,13 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
<span>{fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"}</span> <span>{fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"}</span>
</div> </div>
</div> </div>
<Button <Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={(event) => {
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
handleRemoveItem(item.id); handleRemoveItem(item.id);
}} }} disabled={isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading}>
disabled={isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading}
>
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
</Button> </Button>
</div> </div>);
);
})} })}
</div> </div>
</CardContent> </CardContent>
@@ -1136,8 +879,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
<div className="space-y-4"> <div className="space-y-4">
{batchDetailContent} {batchDetailContent}
</div> </div>
</div> </div>)}
)} </div>);
</div>
);
} }
+6 -16
View File
@@ -1,17 +1,14 @@
import amazonMusicIcon from "../assets/icons/amazon-music.png"; import amazonMusicIcon from "../assets/icons/amazon-music.png";
import qobuzIcon from "../assets/icons/qobuz.png"; import qobuzIcon from "../assets/icons/qobuz.png";
import tidalIcon from "../assets/icons/tidal.png"; import tidalIcon from "../assets/icons/tidal.png";
const PLATFORM_ICON_URLS = { const PLATFORM_ICON_URLS = {
tidal: tidalIcon, tidal: tidalIcon,
qobuz: qobuzIcon, qobuz: qobuzIcon,
amazon: amazonMusicIcon, amazon: amazonMusicIcon,
} as const; } as const;
type PlatformIconProps = { type PlatformIconProps = {
className?: string; className?: string;
}; };
function sanitizeClassName(className: string): string { function sanitizeClassName(className: string): string {
return className return className
.split(/\s+/) .split(/\s+/)
@@ -19,26 +16,26 @@ function sanitizeClassName(className: string): string {
.filter((part) => part !== "fill-current" && part !== "fill-muted-foreground" && !part.startsWith("text-")) .filter((part) => part !== "fill-current" && part !== "fill-muted-foreground" && !part.startsWith("text-"))
.join(" "); .join(" ");
} }
function hasRoundedClass(className: string): boolean { function hasRoundedClass(className: string): boolean {
return className return className
.split(/\s+/) .split(/\s+/)
.some((part) => part.startsWith("rounded")); .some((part) => part.startsWith("rounded"));
} }
function getStatusClasses(className: string): string { function getStatusClasses(className: string): string {
if (className.includes("text-green-500")) { if (className.includes("text-green-500")) {
return "ring-2 ring-green-500 rounded-sm"; return "ring-2 ring-green-500 rounded-sm";
} }
if (className.includes("text-red-500")) { if (className.includes("text-red-500")) {
return "ring-2 ring-red-500 rounded-sm opacity-70"; return "ring-2 ring-red-500 rounded-sm opacity-70";
} }
return ""; return "";
} }
function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }: {
function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }: { src: string; alt: string; className?: string; defaultClassName?: string; }) { src: string;
alt: string;
className?: string;
defaultClassName?: string;
}) {
const cleanedClassName = sanitizeClassName(className); const cleanedClassName = sanitizeClassName(className);
const statusClasses = getStatusClasses(className); const statusClasses = getStatusClasses(className);
const imageClassName = [ const imageClassName = [
@@ -49,36 +46,29 @@ function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }
] ]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
return <img src={src} alt={alt} className={imageClassName} loading="lazy" referrerPolicy="no-referrer"/>; return <img src={src} alt={alt} className={imageClassName} loading="lazy" referrerPolicy="no-referrer"/>;
} }
export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) { export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) {
return <PlatformIcon src={PLATFORM_ICON_URLS.tidal} alt="Tidal" className={className} defaultClassName="rounded-[4px]"/>; return <PlatformIcon src={PLATFORM_ICON_URLS.tidal} alt="Tidal" className={className} defaultClassName="rounded-[4px]"/>;
} }
export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) { export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) {
return <PlatformIcon src={PLATFORM_ICON_URLS.qobuz} alt="Qobuz" className={className}/>; return <PlatformIcon src={PLATFORM_ICON_URLS.qobuz} alt="Qobuz" className={className}/>;
} }
export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) { export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) {
return <PlatformIcon src={PLATFORM_ICON_URLS.amazon} alt="Amazon Music" className={className} defaultClassName="rounded-[4px]"/>; return <PlatformIcon src={PLATFORM_ICON_URLS.amazon} alt="Amazon Music" className={className} defaultClassName="rounded-[4px]"/>;
} }
export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}> return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path> <path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path> <path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>; </svg>;
} }
export function QobuzAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { export function QobuzAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}> return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path> <path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path> <path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>; </svg>;
} }
export function AmazonAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { export function AmazonAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}> return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
+1 -10
View File
@@ -1,22 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { API_SOURCES, checkAllApiStatuses, ensureApiStatusCheckStarted, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
API_SOURCES,
checkAllApiStatuses,
ensureApiStatusCheckStarted,
getApiStatusState,
subscribeApiStatus,
} from "@/lib/api-status";
export function useApiStatus() { export function useApiStatus() {
const [state, setState] = useState(getApiStatusState); const [state, setState] = useState(getApiStatusState);
useEffect(() => { useEffect(() => {
ensureApiStatusCheckStarted(); ensureApiStatusCheckStarted();
return subscribeApiStatus(() => { return subscribeApiStatus(() => {
setState(getApiStatusState()); setState(getApiStatusState());
}); });
}, []); }, []);
return { return {
...state, ...state,
sources: API_SOURCES, sources: API_SOURCES,
+2 -118
View File
@@ -2,21 +2,9 @@ import { useState, useCallback, useRef, useEffect, type MutableRefObject } from
import type { AnalysisResult } from "@/types/api"; import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeDecodedSamples, analyzeSpectrumFromSamples, parseAudioMetadataFromInput, pcm16MonoArrayBufferToFloat32Samples, type AnalysisProgress, type FrontendAnalysisPayload, type ParsedAudioMetadata, } from "@/lib/flac-analysis";
analyzeAudioArrayBuffer,
analyzeAudioFile,
analyzeDecodedSamples,
analyzeSpectrumFromSamples,
parseAudioMetadataFromInput,
pcm16MonoArrayBufferToFloat32Samples,
type AnalysisProgress,
type FrontendAnalysisPayload,
type ParsedAudioMetadata,
} from "@/lib/flac-analysis";
import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
function toWindowFunction(value: string): WindowFunction { function toWindowFunction(value: string): WindowFunction {
switch (value) { switch (value) {
case "hamming": case "hamming":
@@ -28,16 +16,13 @@ function toWindowFunction(value: string): WindowFunction {
return "hann"; return "hann";
} }
} }
function fileNameFromPath(filePath: string): string { function fileNameFromPath(filePath: string): string {
const parts = filePath.split(/[/\\]/); const parts = filePath.split(/[/\\]/);
return parts[parts.length - 1] || filePath; return parts[parts.length - 1] || filePath;
} }
function nextUiTick(): Promise<void> { function nextUiTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0)); return new Promise((resolve) => setTimeout(resolve, 0));
} }
async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise<ArrayBuffer> { async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise<ArrayBuffer> {
const clean = base64.includes(",") ? base64.split(",")[1] : base64; const clean = base64.includes(",") ? base64.split(",")[1] : base64;
const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0; const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
@@ -45,60 +30,48 @@ async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean)
const bytes = new Uint8Array(outputLength); const bytes = new Uint8Array(outputLength);
const chunkSize = 4 * 16384; const chunkSize = 4 * 16384;
let writeOffset = 0; let writeOffset = 0;
for (let offset = 0; offset < clean.length; offset += chunkSize) { for (let offset = 0; offset < clean.length; offset += chunkSize) {
if (shouldCancel?.()) { if (shouldCancel?.()) {
throw new Error("Analysis cancelled"); throw new Error("Analysis cancelled");
} }
const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize)); const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize));
const binary = atob(chunk); const binary = atob(chunk);
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < binary.length; i++) {
bytes[writeOffset++] = binary.charCodeAt(i); bytes[writeOffset++] = binary.charCodeAt(i);
} }
if ((offset / chunkSize) % 4 === 0) { if ((offset / chunkSize) % 4 === 0) {
await nextUiTick(); await nextUiTick();
} }
} }
return bytes.buffer; return bytes.buffer;
} }
let sessionResult: AnalysisResult | null = null; let sessionResult: AnalysisResult | null = null;
let sessionSelectedFilePath = ""; let sessionSelectedFilePath = "";
let sessionError: string | null = null; let sessionError: string | null = null;
let sessionSamples: Float32Array | null = null; let sessionSamples: Float32Array | null = null;
let sessionCurrentAnalysisKey = ""; let sessionCurrentAnalysisKey = "";
const sessionSamplesByKey = new Map<string, Float32Array>(); const sessionSamplesByKey = new Map<string, Float32Array>();
interface ProgressState { interface ProgressState {
percent: number; percent: number;
message: string; message: string;
} }
const DEFAULT_PROGRESS_STATE: ProgressState = { const DEFAULT_PROGRESS_STATE: ProgressState = {
percent: 0, percent: 0,
message: "Preparing analysis...", message: "Preparing analysis...",
}; };
interface CancelToken { interface CancelToken {
cancelled: boolean; cancelled: boolean;
} }
interface AnalyzeExecutionOptions { interface AnalyzeExecutionOptions {
analysisKey?: string; analysisKey?: string;
displayPath?: string; displayPath?: string;
suppressToast?: boolean; suppressToast?: boolean;
} }
export interface AnalyzeExecutionOutcome { export interface AnalyzeExecutionOutcome {
result: AnalysisResult | null; result: AnalysisResult | null;
error: string | null; error: string | null;
cancelled: boolean; cancelled: boolean;
} }
interface WailsWindow extends Window { interface WailsWindow extends Window {
go?: { go?: {
main?: { main?: {
@@ -109,7 +82,6 @@ interface WailsWindow extends Window {
}; };
}; };
} }
interface BackendAnalysisDecodeResponse { interface BackendAnalysisDecodeResponse {
pcm_base64: string; pcm_base64: string;
sample_rate: number; sample_rate: number;
@@ -119,41 +91,34 @@ interface BackendAnalysisDecodeResponse {
bitrate_kbps?: number; bitrate_kbps?: number;
bit_depth?: string; bit_depth?: string;
} }
function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void { function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void {
if (tokenRef.current) { if (tokenRef.current) {
tokenRef.current.cancelled = true; tokenRef.current.cancelled = true;
tokenRef.current = null; tokenRef.current = null;
} }
} }
function createToken(tokenRef: MutableRefObject<CancelToken | null>): CancelToken { function createToken(tokenRef: MutableRefObject<CancelToken | null>): CancelToken {
cancelToken(tokenRef); cancelToken(tokenRef);
const token: CancelToken = { cancelled: false }; const token: CancelToken = { cancelled: false };
tokenRef.current = token; tokenRef.current = token;
return token; return token;
} }
function isCancelledError(error: unknown): boolean { function isCancelledError(error: unknown): boolean {
return error instanceof Error && error.message === "Analysis cancelled"; return error instanceof Error && error.message === "Analysis cancelled";
} }
function toProgressState(progress: AnalysisProgress): ProgressState { function toProgressState(progress: AnalysisProgress): ProgressState {
return { return {
percent: Math.round(Math.max(0, Math.min(100, progress.percent))), percent: Math.round(Math.max(0, Math.min(100, progress.percent))),
message: progress.message, message: progress.message,
}; };
} }
function isDecodeFailure(error: unknown): boolean { function isDecodeFailure(error: unknown): boolean {
return error instanceof Error && /decode/i.test(error.message); return error instanceof Error && /decode/i.test(error.message);
} }
function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: BackendAnalysisDecodeResponse): ParsedAudioMetadata { function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: BackendAnalysisDecodeResponse): ParsedAudioMetadata {
const sampleRate = decoded.sample_rate > 0 ? decoded.sample_rate : parsed.sampleRate; const sampleRate = decoded.sample_rate > 0 ? decoded.sample_rate : parsed.sampleRate;
const bitsPerSample = decoded.bits_per_sample > 0 ? decoded.bits_per_sample : parsed.bitsPerSample; const bitsPerSample = decoded.bits_per_sample > 0 ? decoded.bits_per_sample : parsed.bitsPerSample;
const duration = decoded.duration > 0 ? decoded.duration : parsed.duration; const duration = decoded.duration > 0 ? decoded.duration : parsed.duration;
return { return {
...parsed, ...parsed,
sampleRate, sampleRate,
@@ -164,7 +129,6 @@ function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: Backe
bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps, bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps,
}; };
} }
export function useAudioAnalysis() { export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false); const [analyzing, setAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE); const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
@@ -173,39 +137,32 @@ export function useAudioAnalysis() {
const [error, setError] = useState<string | null>(() => sessionError); const [error, setError] = useState<string | null>(() => sessionError);
const [spectrumLoading, setSpectrumLoading] = useState(false); const [spectrumLoading, setSpectrumLoading] = useState(false);
const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE); const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
const samplesRef = useRef<Float32Array | null>(sessionSamples); const samplesRef = useRef<Float32Array | null>(sessionSamples);
const currentAnalysisKeyRef = useRef(sessionCurrentAnalysisKey); const currentAnalysisKeyRef = useRef(sessionCurrentAnalysisKey);
const analysisTokenRef = useRef<CancelToken | null>(null); const analysisTokenRef = useRef<CancelToken | null>(null);
const spectrumTokenRef = useRef<CancelToken | null>(null); const spectrumTokenRef = useRef<CancelToken | null>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
cancelToken(analysisTokenRef); cancelToken(analysisTokenRef);
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
}; };
}, []); }, []);
const setResultWithSession = useCallback((next: AnalysisResult | null) => { const setResultWithSession = useCallback((next: AnalysisResult | null) => {
sessionResult = next; sessionResult = next;
setResult(next); setResult(next);
}, []); }, []);
const setSelectedFilePathWithSession = useCallback((next: string) => { const setSelectedFilePathWithSession = useCallback((next: string) => {
sessionSelectedFilePath = next; sessionSelectedFilePath = next;
setSelectedFilePath(next); setSelectedFilePath(next);
}, []); }, []);
const setErrorWithSession = useCallback((next: string | null) => { const setErrorWithSession = useCallback((next: string | null) => {
sessionError = next; sessionError = next;
setError(next); setError(next);
}, []); }, []);
const setCurrentAnalysisKey = useCallback((analysisKey: string) => { const setCurrentAnalysisKey = useCallback((analysisKey: string) => {
currentAnalysisKeyRef.current = analysisKey; currentAnalysisKeyRef.current = analysisKey;
sessionCurrentAnalysisKey = analysisKey; sessionCurrentAnalysisKey = analysisKey;
}, []); }, []);
const storeSuccessfulAnalysis = useCallback((analysisKey: string, displayPath: string, payload: FrontendAnalysisPayload) => { const storeSuccessfulAnalysis = useCallback((analysisKey: string, displayPath: string, payload: FrontendAnalysisPayload) => {
sessionSamplesByKey.set(analysisKey, payload.samples); sessionSamplesByKey.set(analysisKey, payload.samples);
samplesRef.current = payload.samples; samplesRef.current = payload.samples;
@@ -215,7 +172,6 @@ export function useAudioAnalysis() {
setSelectedFilePathWithSession(displayPath); setSelectedFilePathWithSession(displayPath);
setErrorWithSession(null); setErrorWithSession(null);
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
const analyzeFile = useCallback(async (file: File, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => { const analyzeFile = useCallback(async (file: File, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => {
if (!file) { if (!file) {
const errorMessage = "No file provided"; const errorMessage = "No file provided";
@@ -226,11 +182,9 @@ export function useAudioAnalysis() {
cancelled: false, cancelled: false,
}; };
} }
const token = createToken(analysisTokenRef); const token = createToken(analysisTokenRef);
const analysisKey = options?.analysisKey || file.name; const analysisKey = options?.analysisKey || file.name;
const displayPath = options?.displayPath || file.name; const displayPath = options?.displayPath || file.name;
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
setAnalyzing(true); setAnalyzing(true);
setAnalysisProgress({ setAnalysisProgress({
@@ -241,12 +195,10 @@ export function useAudioAnalysis() {
setResultWithSession(null); setResultWithSession(null);
setSelectedFilePathWithSession(displayPath); setSelectedFilePathWithSession(displayPath);
setCurrentAnalysisKey(analysisKey); setCurrentAnalysisKey(analysisKey);
try { try {
logger.info(`Analyzing audio file (frontend): ${displayPath}`); logger.info(`Analyzing audio file (frontend): ${displayPath}`);
const start = Date.now(); const start = Date.now();
const prefs = loadAudioAnalysisPreferences(); const prefs = loadAudioAnalysisPreferences();
const payload = await analyzeAudioFile(file, { const payload = await analyzeAudioFile(file, {
fftSize: prefs.fftSize, fftSize: prefs.fftSize,
windowFunction: prefs.windowFunction, windowFunction: prefs.windowFunction,
@@ -254,10 +206,8 @@ export function useAudioAnalysis() {
if (token.cancelled) { if (token.cancelled) {
return; return;
} }
setAnalysisProgress(toProgressState(progress)); setAnalysisProgress(toProgressState(progress));
}, () => token.cancelled); }, () => token.cancelled);
if (token.cancelled) { if (token.cancelled) {
return { return {
result: null, result: null,
@@ -265,12 +215,9 @@ export function useAudioAnalysis() {
cancelled: true, cancelled: true,
}; };
} }
storeSuccessfulAnalysis(analysisKey, displayPath, payload); storeSuccessfulAnalysis(analysisKey, displayPath, payload);
const elapsed = ((Date.now() - start) / 1000).toFixed(2); const elapsed = ((Date.now() - start) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`); logger.success(`Audio analysis completed in ${elapsed}s`);
return { return {
result: payload.result, result: payload.result,
error: null, error: null,
@@ -285,7 +232,6 @@ export function useAudioAnalysis() {
cancelled: true, cancelled: true,
}; };
} }
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`); logger.error(`Analysis error: ${errorMessage}`);
setErrorWithSession(errorMessage); setErrorWithSession(errorMessage);
@@ -293,13 +239,11 @@ export function useAudioAnalysis() {
percent: 0, percent: 0,
message: "Analysis failed", message: "Analysis failed",
}); });
if (!options?.suppressToast) { if (!options?.suppressToast) {
toast.error("Audio Analysis Failed", { toast.error("Audio Analysis Failed", {
description: errorMessage, description: errorMessage,
}); });
} }
return { return {
result: null, result: null,
error: errorMessage, error: errorMessage,
@@ -313,7 +257,6 @@ export function useAudioAnalysis() {
} }
} }
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]); }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]);
const analyzeFilePath = useCallback(async (filePath: string, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => { const analyzeFilePath = useCallback(async (filePath: string, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => {
if (!filePath) { if (!filePath) {
const errorMessage = "No file path provided"; const errorMessage = "No file path provided";
@@ -324,11 +267,9 @@ export function useAudioAnalysis() {
cancelled: false, cancelled: false,
}; };
} }
const token = createToken(analysisTokenRef); const token = createToken(analysisTokenRef);
const analysisKey = options?.analysisKey || filePath; const analysisKey = options?.analysisKey || filePath;
const displayPath = options?.displayPath || filePath; const displayPath = options?.displayPath || filePath;
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
setAnalyzing(true); setAnalyzing(true);
setAnalysisProgress({ setAnalysisProgress({
@@ -339,19 +280,15 @@ export function useAudioAnalysis() {
setResultWithSession(null); setResultWithSession(null);
setSelectedFilePathWithSession(displayPath); setSelectedFilePathWithSession(displayPath);
setCurrentAnalysisKey(analysisKey); setCurrentAnalysisKey(analysisKey);
try { try {
logger.info(`Analyzing audio file (frontend from path): ${filePath}`); logger.info(`Analyzing audio file (frontend from path): ${filePath}`);
const start = Date.now(); const start = Date.now();
const prefs = loadAudioAnalysisPreferences(); const prefs = loadAudioAnalysisPreferences();
const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64; const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64;
if (!readFileAsBase64) { if (!readFileAsBase64) {
throw new Error("ReadFileAsBase64 backend method is unavailable"); throw new Error("ReadFileAsBase64 backend method is unavailable");
} }
let base64Data = await readFileAsBase64(filePath); let base64Data = await readFileAsBase64(filePath);
if (token.cancelled) { if (token.cancelled) {
return { return {
result: null, result: null,
@@ -359,15 +296,12 @@ export function useAudioAnalysis() {
cancelled: true, cancelled: true,
}; };
} }
setAnalysisProgress({ setAnalysisProgress({
percent: 10, percent: 10,
message: "File loaded", message: "File loaded",
}); });
const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled); const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled);
base64Data = ""; base64Data = "";
if (token.cancelled) { if (token.cancelled) {
return { return {
result: null, result: null,
@@ -375,12 +309,10 @@ export function useAudioAnalysis() {
cancelled: true, cancelled: true,
}; };
} }
setAnalysisProgress({ setAnalysisProgress({
percent: 15, percent: 15,
message: "Preparing audio buffer...", message: "Preparing audio buffer...",
}); });
const fileName = fileNameFromPath(filePath); const fileName = fileNameFromPath(filePath);
const input = { const input = {
fileName, fileName,
@@ -391,21 +323,17 @@ export function useAudioAnalysis() {
fftSize: prefs.fftSize, fftSize: prefs.fftSize,
windowFunction: prefs.windowFunction, windowFunction: prefs.windowFunction,
} as const; } as const;
const updateProgress = (progress: AnalysisProgress) => { const updateProgress = (progress: AnalysisProgress) => {
if (token.cancelled) { if (token.cancelled) {
return; return;
} }
const mappedPercent = 10 + (progress.percent * 0.9); const mappedPercent = 10 + (progress.percent * 0.9);
setAnalysisProgress({ setAnalysisProgress({
percent: Math.round(Math.max(0, Math.min(100, mappedPercent))), percent: Math.round(Math.max(0, Math.min(100, mappedPercent))),
message: progress.message, message: progress.message,
}); });
}; };
let payload: FrontendAnalysisPayload; let payload: FrontendAnalysisPayload;
try { try {
payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled); payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled);
} }
@@ -413,21 +341,16 @@ export function useAudioAnalysis() {
if (!isDecodeFailure(err)) { if (!isDecodeFailure(err)) {
throw err; throw err;
} }
const decodeAudioForAnalysis = (window as WailsWindow).go?.main?.App?.DecodeAudioForAnalysis; const decodeAudioForAnalysis = (window as WailsWindow).go?.main?.App?.DecodeAudioForAnalysis;
if (!decodeAudioForAnalysis) { if (!decodeAudioForAnalysis) {
throw err; throw err;
} }
logger.warning(`Browser decoder failed for ${fileName}; trying FFmpeg fallback`); logger.warning(`Browser decoder failed for ${fileName}; trying FFmpeg fallback`);
setAnalysisProgress({ setAnalysisProgress({
percent: 18, percent: 18,
message: "Browser decoder failed, trying FFmpeg fallback...", message: "Browser decoder failed, trying FFmpeg fallback...",
}); });
const decoded = await decodeAudioForAnalysis(filePath); const decoded = await decodeAudioForAnalysis(filePath);
if (token.cancelled) { if (token.cancelled) {
return { return {
result: null, result: null,
@@ -435,20 +358,15 @@ export function useAudioAnalysis() {
cancelled: true, cancelled: true,
}; };
} }
setAnalysisProgress({ setAnalysisProgress({
percent: 24, percent: 24,
message: "Decoding audio with FFmpeg...", message: "Decoding audio with FFmpeg...",
}); });
const pcmBase64 = decoded.pcm_base64 || ""; const pcmBase64 = decoded.pcm_base64 || "";
if (!pcmBase64) { if (!pcmBase64) {
throw new Error("FFmpeg analysis decode returned no PCM data"); throw new Error("FFmpeg analysis decode returned no PCM data");
} }
const pcmBuffer = await base64ToArrayBuffer(pcmBase64, () => token.cancelled); const pcmBuffer = await base64ToArrayBuffer(pcmBase64, () => token.cancelled);
if (token.cancelled) { if (token.cancelled) {
return { return {
result: null, result: null,
@@ -456,22 +374,11 @@ export function useAudioAnalysis() {
cancelled: true, cancelled: true,
}; };
} }
const parsedMetadata = parseAudioMetadataFromInput(input); const parsedMetadata = parseAudioMetadataFromInput(input);
const mergedMetadata = mergeBackendDecodedMetadata(parsedMetadata, decoded); const mergedMetadata = mergeBackendDecodedMetadata(parsedMetadata, decoded);
const samples = pcm16MonoArrayBufferToFloat32Samples(pcmBuffer); const samples = pcm16MonoArrayBufferToFloat32Samples(pcmBuffer);
payload = await analyzeDecodedSamples(input, mergedMetadata, samples, analysisParams, updateProgress, () => token.cancelled, mergedMetadata.duration);
payload = await analyzeDecodedSamples(
input,
mergedMetadata,
samples,
analysisParams,
updateProgress,
() => token.cancelled,
mergedMetadata.duration,
);
} }
if (token.cancelled) { if (token.cancelled) {
return { return {
result: null, result: null,
@@ -479,12 +386,9 @@ export function useAudioAnalysis() {
cancelled: true, cancelled: true,
}; };
} }
storeSuccessfulAnalysis(analysisKey, displayPath, payload); storeSuccessfulAnalysis(analysisKey, displayPath, payload);
const elapsed = ((Date.now() - start) / 1000).toFixed(2); const elapsed = ((Date.now() - start) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`); logger.success(`Audio analysis completed in ${elapsed}s`);
return { return {
result: payload.result, result: payload.result,
error: null, error: null,
@@ -499,7 +403,6 @@ export function useAudioAnalysis() {
cancelled: true, cancelled: true,
}; };
} }
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`); logger.error(`Analysis error: ${errorMessage}`);
setErrorWithSession(errorMessage); setErrorWithSession(errorMessage);
@@ -507,13 +410,11 @@ export function useAudioAnalysis() {
percent: 0, percent: 0,
message: "Analysis failed", message: "Analysis failed",
}); });
if (!options?.suppressToast) { if (!options?.suppressToast) {
toast.error("Audio Analysis Failed", { toast.error("Audio Analysis Failed", {
description: errorMessage, description: errorMessage,
}); });
} }
return { return {
result: null, result: null,
error: errorMessage, error: errorMessage,
@@ -527,7 +428,6 @@ export function useAudioAnalysis() {
} }
} }
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]); }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]);
const loadStoredAnalysis = useCallback((analysisKey: string, nextResult: AnalysisResult, displayPath: string) => { const loadStoredAnalysis = useCallback((analysisKey: string, nextResult: AnalysisResult, displayPath: string) => {
setCurrentAnalysisKey(analysisKey); setCurrentAnalysisKey(analysisKey);
samplesRef.current = sessionSamplesByKey.get(analysisKey) ?? null; samplesRef.current = sessionSamplesByKey.get(analysisKey) ?? null;
@@ -536,28 +436,23 @@ export function useAudioAnalysis() {
setSelectedFilePathWithSession(displayPath); setSelectedFilePathWithSession(displayPath);
setErrorWithSession(null); setErrorWithSession(null);
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
const clearStoredAnalysis = useCallback((analysisKey?: string) => { const clearStoredAnalysis = useCallback((analysisKey?: string) => {
if (analysisKey) { if (analysisKey) {
sessionSamplesByKey.delete(analysisKey); sessionSamplesByKey.delete(analysisKey);
if (currentAnalysisKeyRef.current === analysisKey) { if (currentAnalysisKeyRef.current === analysisKey) {
currentAnalysisKeyRef.current = ""; currentAnalysisKeyRef.current = "";
sessionCurrentAnalysisKey = ""; sessionCurrentAnalysisKey = "";
samplesRef.current = null; samplesRef.current = null;
sessionSamples = null; sessionSamples = null;
} }
return; return;
} }
sessionSamplesByKey.clear(); sessionSamplesByKey.clear();
currentAnalysisKeyRef.current = ""; currentAnalysisKeyRef.current = "";
sessionCurrentAnalysisKey = ""; sessionCurrentAnalysisKey = "";
samplesRef.current = null; samplesRef.current = null;
sessionSamples = null; sessionSamples = null;
}, []); }, []);
const cancelAnalysis = useCallback(() => { const cancelAnalysis = useCallback(() => {
cancelToken(analysisTokenRef); cancelToken(analysisTokenRef);
setAnalyzing(false); setAnalyzing(false);
@@ -568,22 +463,18 @@ export function useAudioAnalysis() {
} }
: DEFAULT_PROGRESS_STATE); : DEFAULT_PROGRESS_STATE);
}, []); }, []);
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
if (!result || !samplesRef.current) { if (!result || !samplesRef.current) {
return null; return null;
} }
const token = createToken(spectrumTokenRef); const token = createToken(spectrumTokenRef);
setSpectrumLoading(true); setSpectrumLoading(true);
setSpectrumProgress({ setSpectrumProgress({
percent: 0, percent: 0,
message: "Preparing FFT...", message: "Preparing FFT...",
}); });
try { try {
await new Promise<void>((resolve) => setTimeout(resolve, 0)); await new Promise<void>((resolve) => setTimeout(resolve, 0));
const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, { const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, {
fftSize, fftSize,
windowFunction: toWindowFunction(windowFunction), windowFunction: toWindowFunction(windowFunction),
@@ -591,19 +482,15 @@ export function useAudioAnalysis() {
if (token.cancelled) { if (token.cancelled) {
return; return;
} }
setSpectrumProgress(toProgressState(progress)); setSpectrumProgress(toProgressState(progress));
}, () => token.cancelled); }, () => token.cancelled);
if (token.cancelled) { if (token.cancelled) {
return null; return null;
} }
const nextResult = { const nextResult = {
...result, ...result,
spectrum, spectrum,
}; };
setResultWithSession(nextResult); setResultWithSession(nextResult);
return nextResult; return nextResult;
} }
@@ -611,7 +498,6 @@ export function useAudioAnalysis() {
if (isCancelledError(err)) { if (isCancelledError(err)) {
return null; return null;
} }
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum"; const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
logger.error(`Spectrum re-analysis error: ${errorMessage}`); logger.error(`Spectrum re-analysis error: ${errorMessage}`);
setSpectrumProgress({ setSpectrumProgress({
@@ -630,7 +516,6 @@ export function useAudioAnalysis() {
} }
} }
}, [result, setResultWithSession]); }, [result, setResultWithSession]);
const clearResult = useCallback(() => { const clearResult = useCallback(() => {
cancelToken(analysisTokenRef); cancelToken(analysisTokenRef);
cancelToken(spectrumTokenRef); cancelToken(spectrumTokenRef);
@@ -646,7 +531,6 @@ export function useAudioAnalysis() {
samplesRef.current = null; samplesRef.current = null;
sessionSamples = null; sessionSamples = null;
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
return { return {
analyzing, analyzing,
analysisProgress, analysisProgress,
+1 -5
View File
@@ -21,11 +21,7 @@ export function useAvailability() {
setError(null); setError(null);
try { try {
logger.info(`Checking availability for track: ${spotifyId}`); logger.info(`Checking availability for track: ${spotifyId}`);
const response = await withTimeout( const response = await withTimeout(CheckTrackAvailability(spotifyId), CHECK_TIMEOUT_MS, `Availability check timed out after 10 seconds for ${spotifyId}`);
CheckTrackAvailability(spotifyId),
CHECK_TIMEOUT_MS,
`Availability check timed out after 10 seconds for ${spotifyId}`,
);
const availability: TrackAvailability = JSON.parse(response); const availability: TrackAvailability = JSON.parse(response);
setAvailabilityMap((prev) => { setAvailabilityMap((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
+5 -27
View File
@@ -1,15 +1,12 @@
import { CheckAPIStatus } from "../../wailsjs/go/main/App"; import { CheckAPIStatus } from "../../wailsjs/go/main/App";
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout"; import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource { export interface ApiSource {
id: string; id: string;
type: string; type: string;
name: string; name: string;
url: string; url: string;
} }
export const API_SOURCES: ApiSource[] = [ export const API_SOURCES: ApiSource[] = [
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" }, { id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" }, { id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
@@ -23,32 +20,25 @@ export const API_SOURCES: ApiSource[] = [
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" }, { id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" },
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" }, { id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" },
]; ];
type ApiStatusState = { type ApiStatusState = {
isCheckingAll: boolean; isCheckingAll: boolean;
statuses: Record<string, ApiCheckStatus>; statuses: Record<string, ApiCheckStatus>;
}; };
let apiStatusState: ApiStatusState = { let apiStatusState: ApiStatusState = {
isCheckingAll: false, isCheckingAll: false,
statuses: {}, statuses: {},
}; };
let activeCheckAll: Promise<void> | null = null; let activeCheckAll: Promise<void> | null = null;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
function emitApiStatusChange() { function emitApiStatusChange() {
for (const listener of listeners) { for (const listener of listeners) {
listener(); listener();
} }
} }
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) { function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
apiStatusState = updater(apiStatusState); apiStatusState = updater(apiStatusState);
emitApiStatusChange(); emitApiStatusChange();
} }
async function checkSingleApiStatus(source: ApiSource): Promise<void> { async function checkSingleApiStatus(source: ApiSource): Promise<void> {
setApiStatusState((current) => ({ setApiStatusState((current) => ({
...current, ...current,
@@ -57,14 +47,8 @@ async function checkSingleApiStatus(source: ApiSource): Promise<void> {
[source.id]: "checking", [source.id]: "checking",
}, },
})); }));
try { try {
const isOnline = await withTimeout( const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.url}`);
CheckAPIStatus(source.type, source.url),
CHECK_TIMEOUT_MS,
`API status check timed out after 10 seconds for ${source.url}`,
);
setApiStatusState((current) => ({ setApiStatusState((current) => ({
...current, ...current,
statuses: { statuses: {
@@ -72,7 +56,8 @@ async function checkSingleApiStatus(source: ApiSource): Promise<void> {
[source.id]: isOnline ? "online" : "offline", [source.id]: isOnline ? "online" : "offline",
}, },
})); }));
} catch { }
catch {
setApiStatusState((current) => ({ setApiStatusState((current) => ({
...current, ...current,
statuses: { statuses: {
@@ -82,45 +67,39 @@ async function checkSingleApiStatus(source: ApiSource): Promise<void> {
})); }));
} }
} }
export function getApiStatusState(): ApiStatusState { export function getApiStatusState(): ApiStatusState {
return apiStatusState; return apiStatusState;
} }
export function subscribeApiStatus(listener: () => void): () => void { export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener); listeners.add(listener);
return () => { return () => {
listeners.delete(listener); listeners.delete(listener);
}; };
} }
export function hasApiStatusResults(): boolean { export function hasApiStatusResults(): boolean {
return API_SOURCES.some((source) => { return API_SOURCES.some((source) => {
const status = apiStatusState.statuses[source.id]; const status = apiStatusState.statuses[source.id];
return status === "online" || status === "offline"; return status === "online" || status === "offline";
}); });
} }
export function ensureApiStatusCheckStarted(): void { export function ensureApiStatusCheckStarted(): void {
if (!activeCheckAll && !hasApiStatusResults()) { if (!activeCheckAll && !hasApiStatusResults()) {
void checkAllApiStatuses(); void checkAllApiStatuses();
} }
} }
export async function checkAllApiStatuses(): Promise<void> { export async function checkAllApiStatuses(): Promise<void> {
if (activeCheckAll) { if (activeCheckAll) {
return activeCheckAll; return activeCheckAll;
} }
activeCheckAll = (async () => { activeCheckAll = (async () => {
setApiStatusState((current) => ({ setApiStatusState((current) => ({
...current, ...current,
isCheckingAll: true, isCheckingAll: true,
})); }));
try { try {
await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source))); await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source)));
} finally { }
finally {
setApiStatusState((current) => ({ setApiStatusState((current) => ({
...current, ...current,
isCheckingAll: false, isCheckingAll: false,
@@ -128,6 +107,5 @@ export async function checkAllApiStatuses(): Promise<void> {
activeCheckAll = null; activeCheckAll = null;
} }
})(); })();
return activeCheckAll; return activeCheckAll;
} }
+1 -7
View File
@@ -1,15 +1,9 @@
export const CHECK_TIMEOUT_MS = 10 * 1000; export const CHECK_TIMEOUT_MS = 10 * 1000;
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number = CHECK_TIMEOUT_MS, message: string = `Operation timed out after ${Math.round(timeoutMs / 1000)} seconds`): Promise<T> {
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number = CHECK_TIMEOUT_MS,
message: string = `Operation timed out after ${Math.round(timeoutMs / 1000)} seconds`,
): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
reject(new Error(message)); reject(new Error(message));
}, timeoutMs); }, timeoutMs);
promise promise
.then((value) => { .then((value) => {
window.clearTimeout(timer); window.clearTimeout(timer);