diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx index 93032c8..7b5e661 100644 --- a/frontend/src/components/AboutPage.tsx +++ b/frontend/src/components/AboutPage.tsx @@ -15,16 +15,13 @@ import KofiLogo from "@/assets/ko-fi.gif"; import KofiSvg from "@/assets/kofi_symbol.svg"; import UsdtBarcode from "@/assets/usdt.jpg"; import { langColors } from "@/assets/github-lang-colors"; - const browserExtensionItems = [ { icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" }, { icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" }, { 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" }, ]; - const projectCardClass = "cursor-pointer transition-colors hover:bg-muted/50 dark:hover:bg-accent/50"; - export function AboutPage() { const [activeTab, setActiveTab] = useState<"projects" | "support">("projects"); const [repoStats, setRepoStats] = useState>({}); @@ -321,14 +318,12 @@ export function AboutPage() { Browser Extensions & Scripts - {browserExtensionItems.map((item) => ( -
+ {browserExtensionItems.map((item) => (
{item.alt}/ {item.label} -
- ))} +
))}
diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index 902f939..d1bb34f 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -2,7 +2,6 @@ import { Button } from "@/components/ui/button"; import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { useApiStatus } from "@/hooks/useApiStatus"; - export function ApiStatusTab() { const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus(); return (
diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index 74061ba..b31badf 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -13,14 +13,11 @@ import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { GetFileSizes, ListAudioFilesInDir, SaveSpectrumImage, SelectAudioFiles, SelectFolder } from "../../wailsjs/go/main/App"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; - interface AudioAnalysisPageProps { onBack?: () => void; } - type BatchItemStatus = "pending" | "analyzing" | "success" | "error"; type BatchItemSource = "path" | "browser"; - interface BatchAnalysisItem { id: string; source: BatchItemSource; @@ -32,19 +29,16 @@ interface BatchAnalysisItem { result?: AnalysisResult; file?: File; } - interface QueueProgressState { completed: number; total: number; fileName: string; } - const EMPTY_PROGRESS_STATE: QueueProgressState = { completed: 0, total: 0, fileName: "", }; - const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"]; const SUPPORTED_AUDIO_ACCEPT = [ ".flac", @@ -85,11 +79,9 @@ function fileNameFromPath(filePath: string): string { const parts = filePath.split(/[/\\]/); return parts[parts.length - 1] || filePath; } - function browserFileId(file: File): string { return `browser:${file.name}:${file.size}:${file.lastModified}`; } - function downloadDataURL(dataUrl: string, fileName: string): void { const link = document.createElement("a"); link.href = dataUrl; @@ -98,24 +90,20 @@ function downloadDataURL(dataUrl: string, fileName: string): void { link.click(); document.body.removeChild(link); } - function formatFileSize(bytes: number): string { if (bytes <= 0) { return "0 B"; } - const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; 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]}`; } - function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, "0")}`; } - function itemMetaLine(item: BatchAnalysisItem): string { if (item.result) { const parts = [ @@ -123,14 +111,11 @@ function itemMetaLine(item: BatchAnalysisItem): string { `${(item.result.sample_rate / 1000).toFixed(1)} kHz`, formatDuration(item.result.duration), ]; - if (typeof item.result.bitrate_kbps === "number" && item.result.bitrate_kbps > 0) { parts.push(`${item.result.bitrate_kbps} kbps`); } - return parts.join(" • "); } - switch (item.status) { case "analyzing": return "Analyzing audio quality..."; @@ -141,34 +126,21 @@ function itemMetaLine(item: BatchAnalysisItem): string { return "Waiting to be analyzed"; } } - function statusIcon(status: BatchItemStatus) { switch (status) { case "analyzing": - return ; + return ; case "success": - return ; + return ; case "error": - return ; + return ; case "pending": default: - return ; + return ; } } export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - const { - analysisProgress, - spectrumLoading, - spectrumProgress, - analyzeFile, - analyzeFilePath, - cancelAnalysis, - loadStoredAnalysis, - clearStoredAnalysis, - reAnalyzeSpectrum, - clearResult, - } = useAudioAnalysis(); - + const { analysisProgress, spectrumLoading, spectrumProgress, analyzeFile, analyzeFilePath, cancelAnalysis, loadStoredAnalysis, clearStoredAnalysis, reAnalyzeSpectrum, clearResult, } = useAudioAnalysis(); const [items, setItems] = useState([]); const [activeItemId, setActiveItemId] = useState(null); const [isDragging, setIsDragging] = useState(false); @@ -177,53 +149,43 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { const [isBatchRunning, setIsBatchRunning] = useState(false); const [batchProgress, setBatchProgress] = useState(EMPTY_PROGRESS_STATE); const [exportProgress, setExportProgress] = useState(EMPTY_PROGRESS_STATE); - const fileInputRef = useRef(null); const spectrumRef = useRef(null); const batchRunIdRef = useRef(0); const itemsRef = useRef(items); const activeItemIdRef = useRef(activeItemId); - useEffect(() => { itemsRef.current = items; }, [items]); - useEffect(() => { activeItemIdRef.current = activeItemId; }, [activeItemId]); - const setActiveSelection = useCallback((nextId: string | null) => { activeItemIdRef.current = nextId; setActiveItemId(nextId); }, []); - const activeItem = items.find((item) => item.id === activeItemId) ?? null; const successItems = items.filter((item) => item.status === "success" && item.result?.spectrum); const pendingItems = items.filter((item) => item.status === "pending"); const isSingleMode = items.length === 1; const isBatchMode = items.length > 1; const canResumeBatch = isBatchMode && !isBatchRunning && pendingItems.length > 0; - const batchPercent = batchProgress.total > 0 ? Math.round(Math.max(0, Math.min(100, ((batchProgress.completed + (isBatchRunning ? analysisProgress.percent / 100 : 0)) / batchProgress.total) * 100))) : 0; const exportPercent = exportProgress.total > 0 ? Math.round(Math.max(0, Math.min(100, (exportProgress.completed / exportProgress.total) * 100))) : 0; - useEffect(() => { if (!activeItem?.result) { return; } - loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path); }, [activeItem, loadStoredAnalysis]); - const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => { if (entries.length === 0) { return; } - const runId = batchRunIdRef.current + 1; batchRunIdRef.current = runId; setIsBatchRunning(true); @@ -232,27 +194,22 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { total: entries.length, fileName: entries[0]?.name ?? "", }); - let successCount = 0; let failCount = 0; - try { for (let index = 0; index < entries.length; index++) { if (batchRunIdRef.current !== runId) { return; } - const entry = entries[index]; setBatchProgress({ completed: index, total: entries.length, fileName: entry.name, }); - setItems((prev) => prev.map((item) => item.id === entry.id ? { ...item, status: "analyzing", error: undefined } : item)); - const outcome = entry.source === "browser" && entry.file ? await analyzeFile(entry.file, { analysisKey: entry.id, @@ -264,15 +221,12 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { displayPath: entry.path, suppressToast: true, }); - if (batchRunIdRef.current !== runId) { return; } - if (outcome.cancelled) { return; } - if (outcome.result) { const analysisResult = outcome.result; successCount++; @@ -285,7 +239,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { size: analysisResult.file_size || item.size, } : item)); - const hasSelectedSuccess = itemsRef.current.some((item) => item.id === activeItemIdRef.current && item.status === "success" && item.result); if (!hasSelectedSuccess) { setActiveSelection(entry.id); @@ -300,20 +253,17 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { error: outcome.error || "Analysis failed", } : item)); - if (!activeItemIdRef.current) { setActiveSelection(entry.id); } } } - if (batchRunIdRef.current === runId) { setBatchProgress({ completed: entries.length, total: entries.length, fileName: "", }); - if (successCount > 0) { toast.success("Batch Analysis Complete", { description: `Successfully analyzed ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, @@ -332,47 +282,38 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { } } }, [analyzeFile, analyzeFilePath, setActiveSelection]); - const ensureIdleQueue = useCallback(() => { if (!isBatchRunning) { return true; } - toast.info("Analysis in progress", { description: "Please wait for the current batch to finish or clear it first.", }); return false; }, [isBatchRunning]); - const addPathItems = useCallback(async (paths: string[]) => { if (!ensureIdleQueue()) { return; } - const uniquePaths = Array.from(new Set(paths.filter(Boolean))); const invalidCount = uniquePaths.filter((path) => !isSupportedAudioPath(path)).length; const validPaths = uniquePaths.filter(isSupportedAudioPath); - if (invalidCount > 0) { toast.error("Unsupported format", { description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, }); } - if (validPaths.length === 0) { return; } - const existingIds = new Set(itemsRef.current.map((item) => item.id)); const newPaths = validPaths.filter((path) => !existingIds.has(path)); - if (newPaths.length === 0) { toast.info("No new files added", { description: "All selected files were already in the batch queue.", }); return; } - const fileSizes = await GetFileSizes(newPaths); const newItems = newPaths.map((path) => ({ id: path, @@ -382,78 +323,64 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { size: fileSizes[path] || 0, status: "pending" as const, })); - if (validPaths.length !== newPaths.length) { toast.info("Some files skipped", { description: `${validPaths.length - newPaths.length} file(s) were already queued.`, }); } - setItems((prev) => [...prev, ...newItems]); if (!activeItemIdRef.current) { setActiveSelection(newItems[0]?.id ?? null); } - void runBatchAnalysis(newItems); }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); - const addBrowserFiles = useCallback(async (files: File[]) => { if (!ensureIdleQueue()) { return; } - const validFiles = files.filter(isSupportedAudioFile); const invalidCount = files.length - validFiles.length; - if (invalidCount > 0) { toast.error("Unsupported format", { description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, }); } - if (validFiles.length === 0) { return; } - const existingIds = new Set(itemsRef.current.map((item) => item.id)); const newItems = validFiles .map((file) => ({ - id: browserFileId(file), - source: "browser" as const, - path: file.name, - name: file.name, - size: file.size, - status: "pending" as const, - file, - })) + id: browserFileId(file), + source: "browser" as const, + path: file.name, + name: file.name, + size: file.size, + status: "pending" as const, + file, + })) .filter((item) => !existingIds.has(item.id)); - if (newItems.length === 0) { toast.info("No new files added", { description: "All selected files were already in the batch queue.", }); return; } - if (validFiles.length !== newItems.length) { toast.info("Some files skipped", { description: `${validFiles.length - newItems.length} file(s) were already queued.`, }); } - setItems((prev) => [...prev, ...newItems]); if (!activeItemIdRef.current) { setActiveSelection(newItems[0]?.id ?? null); } - void runBatchAnalysis(newItems); }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); - const handleSelectFiles = useCallback(async () => { if (!ensureIdleQueue()) { return; } - try { const selectedPaths = await SelectAudioFiles(); if (selectedPaths && selectedPaths.length > 0) { @@ -466,18 +393,15 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { return; } }, [addPathItems, ensureIdleQueue]); - const handleSelectFolder = useCallback(async () => { if (!ensureIdleQueue()) { return; } - try { const selectedFolder = await SelectFolder(""); if (!selectedFolder) { return; } - const folderFiles = await ListAudioFilesInDir(selectedFolder); if (!folderFiles || folderFiles.length === 0) { toast.info("No audio files found", { @@ -485,7 +409,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }); return; } - await addPathItems(folderFiles.map((file) => file.path)); } catch (err) { @@ -494,59 +417,46 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }); } }, [addPathItems, ensureIdleQueue]); - const handleInputChange = useCallback(async (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; - if (files.length === 0) { return; } - await addBrowserFiles(files); }, [addBrowserFiles]); - const handleHtmlDrop = useCallback(async (event: DragEvent) => { event.preventDefault(); setIsDragging(false); - const files = Array.from(event.dataTransfer.files ?? []); if (files.length === 0) { return; } - await addBrowserFiles(files); }, [addBrowserFiles]); useEffect(() => { OnFileDrop((_x, _y, paths) => { setIsDragging(false); - if (!paths || paths.length === 0) { return; } - void addPathItems(paths); }, true); - return () => { OnFileDropOff(); }; }, [addPathItems]); - const handleSelectItem = useCallback((itemId: string) => { setActiveSelection(itemId); }, [setActiveSelection]); - const handleRemoveItem = useCallback((itemId: string) => { if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { return; } - clearStoredAnalysis(itemId); const nextItems = itemsRef.current.filter((item) => item.id !== itemId); itemsRef.current = nextItems; setItems(nextItems); - if (activeItemIdRef.current === itemId) { const nextActive = nextItems.find((item) => item.status === "success" && item.result) ?? nextItems[0] ?? null; setActiveSelection(nextActive?.id ?? null); @@ -555,12 +465,10 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { } } }, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]); - const handleClearAll = useCallback(() => { if (isExportingBatch || isExportingSelected) { return; } - batchRunIdRef.current += 1; itemsRef.current = []; setItems([]); @@ -572,12 +480,10 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { setExportProgress(EMPTY_PROGRESS_STATE); setIsDragging(false); }, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]); - const handleStopBatch = useCallback(() => { if (!isBatchRunning) { return; } - batchRunIdRef.current += 1; cancelAnalysis(); setIsBatchRunning(false); @@ -592,25 +498,20 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { description: "Click Analyze to continue the remaining files.", }); }, [cancelAnalysis, isBatchRunning]); - const handleAnalyzePending = useCallback(() => { if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { return; } - const nextPendingItems = itemsRef.current.filter((item) => item.status === "pending"); if (nextPendingItems.length === 0) { return; } - void runBatchAnalysis(nextPendingItems); }, [isBatchRunning, isExportingBatch, isExportingSelected, runBatchAnalysis, spectrumLoading]); - const handleExportSelected = useCallback(async () => { if (!activeItem?.result?.spectrum || !spectrumRef.current) { return; } - const dataUrl = spectrumRef.current.getCanvasDataURL(); if (!dataUrl) { toast.error("Export Failed", { @@ -618,9 +519,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }); return; } - setIsExportingSelected(true); - try { if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) { const outPath = await SaveSpectrumImage(activeItem.path, dataUrl); @@ -629,7 +528,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { }); return; } - const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram"; downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); toast.success("PNG Exported", { @@ -645,17 +543,14 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { setIsExportingSelected(false); } }, [activeItem]); - const handleBatchExport = useCallback(async () => { const exportableItems = itemsRef.current.filter((item) => item.status === "success" && item.result?.spectrum); - if (exportableItems.length === 0) { toast.error("Nothing to export", { description: "Analyze at least one file successfully before exporting PNGs.", }); return; } - const preferences = loadAudioAnalysisPreferences(); setIsExportingBatch(true); setExportProgress({ @@ -663,26 +558,21 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { total: exportableItems.length, fileName: exportableItems[0]?.name ?? "", }); - let successCount = 0; let failCount = 0; - try { for (let index = 0; index < exportableItems.length; index++) { const item = exportableItems[index]; const result = item.result; - if (!result?.spectrum) { failCount++; continue; } - setExportProgress({ completed: index, total: exportableItems.length, fileName: item.name, }); - try { const dataUrl = await createSpectrogramDataURL({ spectrumData: result.spectrum, @@ -692,7 +582,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { colorScheme: preferences.colorScheme, fileName: item.name, }); - if (item.source === "path" && isAbsolutePath(item.path)) { await SaveSpectrumImage(item.path, dataUrl); } @@ -700,22 +589,18 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { const baseName = item.name.replace(/\.[^/.]+$/, "") || "spectrogram"; downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); } - successCount++; } catch { failCount++; } - await new Promise((resolve) => setTimeout(resolve, 0)); } - setExportProgress({ completed: exportableItems.length, total: exportableItems.length, fileName: "", }); - if (successCount > 0) { toast.success("Batch PNG Export Complete", { description: `Exported ${successCount} spectrogram PNG file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, @@ -731,17 +616,14 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { setIsExportingBatch(false); } }, []); - const handleReAnalyzeSelectedSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { if (!activeItem?.result) { return; } - const nextResult = await reAnalyzeSpectrum(fftSize, windowFunction); if (!nextResult) { return; } - setItems((prev) => prev.map((item) => item.id === activeItem.id ? { ...item, @@ -751,285 +633,167 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { } : item)); }, [activeItem, reAnalyzeSpectrum]); - - const batchDetailContent = !activeItem ? ( - + const batchDetailContent = !activeItem ? (

Select a file from the batch queue to inspect its analysis result.

-
- ) : activeItem.status !== "success" || !activeItem.result ? ( - + ) : activeItem.status !== "success" || !activeItem.result ? ( {activeItem.name}

{activeItem.path}

- {activeItem.status === "analyzing" && ( -
+ {activeItem.status === "analyzing" && (
Analyzing audio quality...
- +

{analysisProgress.message}

-
- )} - {activeItem.status === "pending" && ( -

+

)} + {activeItem.status === "pending" && (

This file is queued and waiting for batch analysis to start. -

- )} - {activeItem.status === "error" && ( -
+

)} + {activeItem.status === "error" && (
{activeItem.error || "Analysis failed"} -
- )} +
)}
-
- ) : ( -
- + ) : (
+ - -
- ); + +
); + const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? (
+ - const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? ( -
- - - -
- ) : activeItem.status === "analyzing" || activeItem.status === "pending" ? ( -
+ +
) : activeItem.status === "analyzing" || activeItem.status === "pending" ? (
{activeItem.status === "pending" ? "Preparing..." : "Processing..."} {analysisProgress.percent}%
- +

{analysisProgress.message}

-
- ) : ( -
+
) : (
{activeItem.error || "Analysis failed"}
-
- ); - +
); const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result; - - return ( -
- + return (
+
- {onBack && ( - - )} + {onBack && ()}

Audio Quality Analyzer

- {isBatchMode && isBatchRunning && ( - - )} - {canResumeBatch && ( - )} + {canResumeBatch && ( - )} - {isBatchMode && ( - + )} + {isBatchMode && ( - + Add Files - + Add Folder - - )} - {showSingleModeActions && ( - - )} - {isBatchMode && ( - + )} + {isBatchMode && ( - - + Export Selected PNG - + Export All PNG - - )} - {showSingleModeActions && ( - - )} - {isBatchMode && ( - )} + {isBatchMode && ( - )} + )}
- {items.length === 0 && ( -
{ - event.preventDefault(); - setIsDragging(true); - }} - onDragLeave={(event) => { - event.preventDefault(); - setIsDragging(false); - }} - onDrop={handleHtmlDrop} - style={{ "--wails-drop-target": "drop" } as CSSProperties} - > + {items.length === 0 && (
{ + event.preventDefault(); + setIsDragging(true); + }} onDragLeave={(event) => { + event.preventDefault(); + setIsDragging(false); + }} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
- +

{isDragging - ? "Drop your audio files here" - : "Drag and drop audio files here, or click the button below to select"} + ? "Drop your audio files here" + : "Drag and drop audio files here, or click the button below to select"}

Supported formats: FLAC, MP3, M4A, AAC

-
- )} +
)} - {isSingleMode && ( -
+ {isSingleMode && (
{singleModeContent} -
- )} +
)} - {isBatchMode && ( -
+ {isBatchMode && (
- {(isBatchRunning || isExportingBatch) && ( - + {(isBatchRunning || isExportingBatch) && ( {isExportingBatch ? "Batch PNG Export" : "Batch Analysis"} @@ -1039,25 +803,22 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
{isExportingBatch - ? exportProgress.fileName || "Preparing export..." - : batchProgress.fileName || analysisProgress.message} + ? exportProgress.fileName || "Preparing export..." + : batchProgress.fileName || analysisProgress.message} {isExportingBatch - ? `${exportProgress.completed}/${exportProgress.total}` - : `${Math.min(batchProgress.completed + (isBatchRunning ? 1 : 0), batchProgress.total)}/${batchProgress.total}`} + ? `${exportProgress.completed}/${exportProgress.total}` + : `${Math.min(batchProgress.completed + (isBatchRunning ? 1 : 0), batchProgress.total)}/${batchProgress.total}`}
- - {!isExportingBatch && ( -
+ + {!isExportingBatch && (
{analysisProgress.message} {analysisProgress.percent}% -
- )} +
)} -
- )} +
)} @@ -1071,36 +832,26 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
{items.map((item) => { - const isActive = item.id === activeItemId; - const isSelectable = item.status !== "pending"; - return ( -
{ - if (!isSelectable) { - return; - } - handleSelectItem(item.id); - }} - onKeyDown={(event) => { - if (!isSelectable) { - return; - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleSelectItem(item.id); - } - }} - > + const isActive = item.id === activeItemId; + const isSelectable = item.status !== "pending"; + return (
{ + if (!isSelectable) { + return; + } + handleSelectItem(item.id); + }} onKeyDown={(event) => { + if (!isSelectable) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSelectItem(item.id); + } + }}>
{statusIcon(item.status)}

{item.name}

@@ -1112,22 +863,14 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { {fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"}
- -
- ); - })} +
); + })}
@@ -1136,8 +879,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
{batchDetailContent}
-
- )} -
- ); +
)} +
); } diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx index c7b511b..3e98ea0 100644 --- a/frontend/src/components/PlatformIcons.tsx +++ b/frontend/src/components/PlatformIcons.tsx @@ -1,17 +1,14 @@ import amazonMusicIcon from "../assets/icons/amazon-music.png"; import qobuzIcon from "../assets/icons/qobuz.png"; import tidalIcon from "../assets/icons/tidal.png"; - const PLATFORM_ICON_URLS = { tidal: tidalIcon, qobuz: qobuzIcon, amazon: amazonMusicIcon, } as const; - type PlatformIconProps = { className?: string; }; - function sanitizeClassName(className: string): string { return className .split(/\s+/) @@ -19,26 +16,26 @@ function sanitizeClassName(className: string): string { .filter((part) => part !== "fill-current" && part !== "fill-muted-foreground" && !part.startsWith("text-")) .join(" "); } - function hasRoundedClass(className: string): boolean { return className .split(/\s+/) .some((part) => part.startsWith("rounded")); } - function getStatusClasses(className: string): string { if (className.includes("text-green-500")) { return "ring-2 ring-green-500 rounded-sm"; } - if (className.includes("text-red-500")) { return "ring-2 ring-red-500 rounded-sm opacity-70"; } - return ""; } - -function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }: { src: string; alt: string; className?: string; defaultClassName?: string; }) { +function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }: { + src: string; + alt: string; + className?: string; + defaultClassName?: string; +}) { const cleanedClassName = sanitizeClassName(className); const statusClasses = getStatusClasses(className); const imageClassName = [ @@ -49,36 +46,29 @@ function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" } ] .filter(Boolean) .join(" "); - - return {alt}; + return {alt}; } - export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; } - export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; } - export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; } - export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { return ; } - export function QobuzAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { return ; } - export function AmazonAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { return diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 316f3d9..60b0218 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -245,13 +245,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin - Songlink + Songlink Songlink - Songstats + Songstats Songstats diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index ee52ddf..5929992 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -68,7 +68,7 @@ export function TitleBar() { openExternal("https://afkarxyz.qzz.io")} className="gap-2"> - + Website diff --git a/frontend/src/hooks/useApiStatus.ts b/frontend/src/hooks/useApiStatus.ts index b74373d..589a730 100644 --- a/frontend/src/hooks/useApiStatus.ts +++ b/frontend/src/hooks/useApiStatus.ts @@ -1,22 +1,13 @@ import { useEffect, useState } from "react"; -import { - API_SOURCES, - checkAllApiStatuses, - ensureApiStatusCheckStarted, - getApiStatusState, - subscribeApiStatus, -} from "@/lib/api-status"; - +import { API_SOURCES, checkAllApiStatuses, ensureApiStatusCheckStarted, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status"; export function useApiStatus() { const [state, setState] = useState(getApiStatusState); - useEffect(() => { ensureApiStatusCheckStarted(); return subscribeApiStatus(() => { setState(getApiStatusState()); }); }, []); - return { ...state, sources: API_SOURCES, diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts index 672f006..0689e6e 100644 --- a/frontend/src/hooks/useAudioAnalysis.ts +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -2,21 +2,9 @@ import { useState, useCallback, useRef, useEffect, type MutableRefObject } from import type { AnalysisResult } from "@/types/api"; import { logger } from "@/lib/logger"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { - analyzeAudioArrayBuffer, - analyzeAudioFile, - analyzeDecodedSamples, - analyzeSpectrumFromSamples, - parseAudioMetadataFromInput, - pcm16MonoArrayBufferToFloat32Samples, - type AnalysisProgress, - type FrontendAnalysisPayload, - type ParsedAudioMetadata, -} from "@/lib/flac-analysis"; +import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeDecodedSamples, analyzeSpectrumFromSamples, parseAudioMetadataFromInput, pcm16MonoArrayBufferToFloat32Samples, type AnalysisProgress, type FrontendAnalysisPayload, type ParsedAudioMetadata, } from "@/lib/flac-analysis"; import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; - type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular"; - function toWindowFunction(value: string): WindowFunction { switch (value) { case "hamming": @@ -28,16 +16,13 @@ function toWindowFunction(value: string): WindowFunction { return "hann"; } } - function fileNameFromPath(filePath: string): string { const parts = filePath.split(/[/\\]/); return parts[parts.length - 1] || filePath; } - function nextUiTick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } - async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise { const clean = base64.includes(",") ? base64.split(",")[1] : base64; 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 chunkSize = 4 * 16384; let writeOffset = 0; - for (let offset = 0; offset < clean.length; offset += chunkSize) { if (shouldCancel?.()) { throw new Error("Analysis cancelled"); } - const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize)); const binary = atob(chunk); - for (let i = 0; i < binary.length; i++) { bytes[writeOffset++] = binary.charCodeAt(i); } - if ((offset / chunkSize) % 4 === 0) { await nextUiTick(); } } - return bytes.buffer; } - let sessionResult: AnalysisResult | null = null; let sessionSelectedFilePath = ""; let sessionError: string | null = null; let sessionSamples: Float32Array | null = null; let sessionCurrentAnalysisKey = ""; const sessionSamplesByKey = new Map(); - interface ProgressState { percent: number; message: string; } - const DEFAULT_PROGRESS_STATE: ProgressState = { percent: 0, message: "Preparing analysis...", }; - interface CancelToken { cancelled: boolean; } - interface AnalyzeExecutionOptions { analysisKey?: string; displayPath?: string; suppressToast?: boolean; } - export interface AnalyzeExecutionOutcome { result: AnalysisResult | null; error: string | null; cancelled: boolean; } - interface WailsWindow extends Window { go?: { main?: { @@ -109,7 +82,6 @@ interface WailsWindow extends Window { }; }; } - interface BackendAnalysisDecodeResponse { pcm_base64: string; sample_rate: number; @@ -119,41 +91,34 @@ interface BackendAnalysisDecodeResponse { bitrate_kbps?: number; bit_depth?: string; } - function cancelToken(tokenRef: MutableRefObject): void { if (tokenRef.current) { tokenRef.current.cancelled = true; tokenRef.current = null; } } - function createToken(tokenRef: MutableRefObject): CancelToken { cancelToken(tokenRef); const token: CancelToken = { cancelled: false }; tokenRef.current = token; return token; } - function isCancelledError(error: unknown): boolean { return error instanceof Error && error.message === "Analysis cancelled"; } - function toProgressState(progress: AnalysisProgress): ProgressState { return { percent: Math.round(Math.max(0, Math.min(100, progress.percent))), message: progress.message, }; } - function isDecodeFailure(error: unknown): boolean { return error instanceof Error && /decode/i.test(error.message); } - function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: BackendAnalysisDecodeResponse): ParsedAudioMetadata { 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 duration = decoded.duration > 0 ? decoded.duration : parsed.duration; - return { ...parsed, sampleRate, @@ -164,7 +129,6 @@ function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: Backe bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps, }; } - export function useAudioAnalysis() { const [analyzing, setAnalyzing] = useState(false); const [analysisProgress, setAnalysisProgress] = useState(DEFAULT_PROGRESS_STATE); @@ -173,39 +137,32 @@ export function useAudioAnalysis() { const [error, setError] = useState(() => sessionError); const [spectrumLoading, setSpectrumLoading] = useState(false); const [spectrumProgress, setSpectrumProgress] = useState(DEFAULT_PROGRESS_STATE); - const samplesRef = useRef(sessionSamples); const currentAnalysisKeyRef = useRef(sessionCurrentAnalysisKey); const analysisTokenRef = useRef(null); const spectrumTokenRef = useRef(null); - useEffect(() => { return () => { cancelToken(analysisTokenRef); cancelToken(spectrumTokenRef); }; }, []); - const setResultWithSession = useCallback((next: AnalysisResult | null) => { sessionResult = next; setResult(next); }, []); - const setSelectedFilePathWithSession = useCallback((next: string) => { sessionSelectedFilePath = next; setSelectedFilePath(next); }, []); - const setErrorWithSession = useCallback((next: string | null) => { sessionError = next; setError(next); }, []); - const setCurrentAnalysisKey = useCallback((analysisKey: string) => { currentAnalysisKeyRef.current = analysisKey; sessionCurrentAnalysisKey = analysisKey; }, []); - const storeSuccessfulAnalysis = useCallback((analysisKey: string, displayPath: string, payload: FrontendAnalysisPayload) => { sessionSamplesByKey.set(analysisKey, payload.samples); samplesRef.current = payload.samples; @@ -215,7 +172,6 @@ export function useAudioAnalysis() { setSelectedFilePathWithSession(displayPath); setErrorWithSession(null); }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - const analyzeFile = useCallback(async (file: File, options?: AnalyzeExecutionOptions): Promise => { if (!file) { const errorMessage = "No file provided"; @@ -226,11 +182,9 @@ export function useAudioAnalysis() { cancelled: false, }; } - const token = createToken(analysisTokenRef); const analysisKey = options?.analysisKey || file.name; const displayPath = options?.displayPath || file.name; - cancelToken(spectrumTokenRef); setAnalyzing(true); setAnalysisProgress({ @@ -241,12 +195,10 @@ export function useAudioAnalysis() { setResultWithSession(null); setSelectedFilePathWithSession(displayPath); setCurrentAnalysisKey(analysisKey); - try { logger.info(`Analyzing audio file (frontend): ${displayPath}`); const start = Date.now(); const prefs = loadAudioAnalysisPreferences(); - const payload = await analyzeAudioFile(file, { fftSize: prefs.fftSize, windowFunction: prefs.windowFunction, @@ -254,10 +206,8 @@ export function useAudioAnalysis() { if (token.cancelled) { return; } - setAnalysisProgress(toProgressState(progress)); }, () => token.cancelled); - if (token.cancelled) { return { result: null, @@ -265,12 +215,9 @@ export function useAudioAnalysis() { cancelled: true, }; } - storeSuccessfulAnalysis(analysisKey, displayPath, payload); - const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); - return { result: payload.result, error: null, @@ -285,7 +232,6 @@ export function useAudioAnalysis() { cancelled: true, }; } - const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; logger.error(`Analysis error: ${errorMessage}`); setErrorWithSession(errorMessage); @@ -293,13 +239,11 @@ export function useAudioAnalysis() { percent: 0, message: "Analysis failed", }); - if (!options?.suppressToast) { toast.error("Audio Analysis Failed", { description: errorMessage, }); } - return { result: null, error: errorMessage, @@ -313,7 +257,6 @@ export function useAudioAnalysis() { } } }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]); - const analyzeFilePath = useCallback(async (filePath: string, options?: AnalyzeExecutionOptions): Promise => { if (!filePath) { const errorMessage = "No file path provided"; @@ -324,11 +267,9 @@ export function useAudioAnalysis() { cancelled: false, }; } - const token = createToken(analysisTokenRef); const analysisKey = options?.analysisKey || filePath; const displayPath = options?.displayPath || filePath; - cancelToken(spectrumTokenRef); setAnalyzing(true); setAnalysisProgress({ @@ -339,19 +280,15 @@ export function useAudioAnalysis() { setResultWithSession(null); setSelectedFilePathWithSession(displayPath); setCurrentAnalysisKey(analysisKey); - try { logger.info(`Analyzing audio file (frontend from path): ${filePath}`); const start = Date.now(); const prefs = loadAudioAnalysisPreferences(); const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64; - if (!readFileAsBase64) { throw new Error("ReadFileAsBase64 backend method is unavailable"); } - let base64Data = await readFileAsBase64(filePath); - if (token.cancelled) { return { result: null, @@ -359,15 +296,12 @@ export function useAudioAnalysis() { cancelled: true, }; } - setAnalysisProgress({ percent: 10, message: "File loaded", }); - const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled); base64Data = ""; - if (token.cancelled) { return { result: null, @@ -375,12 +309,10 @@ export function useAudioAnalysis() { cancelled: true, }; } - setAnalysisProgress({ percent: 15, message: "Preparing audio buffer...", }); - const fileName = fileNameFromPath(filePath); const input = { fileName, @@ -391,21 +323,17 @@ export function useAudioAnalysis() { fftSize: prefs.fftSize, windowFunction: prefs.windowFunction, } as const; - const updateProgress = (progress: AnalysisProgress) => { if (token.cancelled) { return; } - const mappedPercent = 10 + (progress.percent * 0.9); setAnalysisProgress({ percent: Math.round(Math.max(0, Math.min(100, mappedPercent))), message: progress.message, }); }; - let payload: FrontendAnalysisPayload; - try { payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled); } @@ -413,21 +341,16 @@ export function useAudioAnalysis() { if (!isDecodeFailure(err)) { throw err; } - const decodeAudioForAnalysis = (window as WailsWindow).go?.main?.App?.DecodeAudioForAnalysis; - if (!decodeAudioForAnalysis) { throw err; } - logger.warning(`Browser decoder failed for ${fileName}; trying FFmpeg fallback`); setAnalysisProgress({ percent: 18, message: "Browser decoder failed, trying FFmpeg fallback...", }); - const decoded = await decodeAudioForAnalysis(filePath); - if (token.cancelled) { return { result: null, @@ -435,20 +358,15 @@ export function useAudioAnalysis() { cancelled: true, }; } - setAnalysisProgress({ percent: 24, message: "Decoding audio with FFmpeg...", }); - const pcmBase64 = decoded.pcm_base64 || ""; - if (!pcmBase64) { throw new Error("FFmpeg analysis decode returned no PCM data"); } - const pcmBuffer = await base64ToArrayBuffer(pcmBase64, () => token.cancelled); - if (token.cancelled) { return { result: null, @@ -456,22 +374,11 @@ export function useAudioAnalysis() { cancelled: true, }; } - const parsedMetadata = parseAudioMetadataFromInput(input); const mergedMetadata = mergeBackendDecodedMetadata(parsedMetadata, decoded); 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) { return { result: null, @@ -479,12 +386,9 @@ export function useAudioAnalysis() { cancelled: true, }; } - storeSuccessfulAnalysis(analysisKey, displayPath, payload); - const elapsed = ((Date.now() - start) / 1000).toFixed(2); logger.success(`Audio analysis completed in ${elapsed}s`); - return { result: payload.result, error: null, @@ -499,7 +403,6 @@ export function useAudioAnalysis() { cancelled: true, }; } - const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; logger.error(`Analysis error: ${errorMessage}`); setErrorWithSession(errorMessage); @@ -507,13 +410,11 @@ export function useAudioAnalysis() { percent: 0, message: "Analysis failed", }); - if (!options?.suppressToast) { toast.error("Audio Analysis Failed", { description: errorMessage, }); } - return { result: null, error: errorMessage, @@ -527,7 +428,6 @@ export function useAudioAnalysis() { } } }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]); - const loadStoredAnalysis = useCallback((analysisKey: string, nextResult: AnalysisResult, displayPath: string) => { setCurrentAnalysisKey(analysisKey); samplesRef.current = sessionSamplesByKey.get(analysisKey) ?? null; @@ -536,28 +436,23 @@ export function useAudioAnalysis() { setSelectedFilePathWithSession(displayPath); setErrorWithSession(null); }, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - const clearStoredAnalysis = useCallback((analysisKey?: string) => { if (analysisKey) { sessionSamplesByKey.delete(analysisKey); - if (currentAnalysisKeyRef.current === analysisKey) { currentAnalysisKeyRef.current = ""; sessionCurrentAnalysisKey = ""; samplesRef.current = null; sessionSamples = null; } - return; } - sessionSamplesByKey.clear(); currentAnalysisKeyRef.current = ""; sessionCurrentAnalysisKey = ""; samplesRef.current = null; sessionSamples = null; }, []); - const cancelAnalysis = useCallback(() => { cancelToken(analysisTokenRef); setAnalyzing(false); @@ -568,22 +463,18 @@ export function useAudioAnalysis() { } : DEFAULT_PROGRESS_STATE); }, []); - const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { if (!result || !samplesRef.current) { return null; } - const token = createToken(spectrumTokenRef); setSpectrumLoading(true); setSpectrumProgress({ percent: 0, message: "Preparing FFT...", }); - try { await new Promise((resolve) => setTimeout(resolve, 0)); - const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, { fftSize, windowFunction: toWindowFunction(windowFunction), @@ -591,19 +482,15 @@ export function useAudioAnalysis() { if (token.cancelled) { return; } - setSpectrumProgress(toProgressState(progress)); }, () => token.cancelled); - if (token.cancelled) { return null; } - const nextResult = { ...result, spectrum, }; - setResultWithSession(nextResult); return nextResult; } @@ -611,7 +498,6 @@ export function useAudioAnalysis() { if (isCancelledError(err)) { return null; } - const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum"; logger.error(`Spectrum re-analysis error: ${errorMessage}`); setSpectrumProgress({ @@ -630,7 +516,6 @@ export function useAudioAnalysis() { } } }, [result, setResultWithSession]); - const clearResult = useCallback(() => { cancelToken(analysisTokenRef); cancelToken(spectrumTokenRef); @@ -646,7 +531,6 @@ export function useAudioAnalysis() { samplesRef.current = null; sessionSamples = null; }, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]); - return { analyzing, analysisProgress, diff --git a/frontend/src/hooks/useAvailability.ts b/frontend/src/hooks/useAvailability.ts index 440fbe1..c835705 100644 --- a/frontend/src/hooks/useAvailability.ts +++ b/frontend/src/hooks/useAvailability.ts @@ -21,11 +21,7 @@ export function useAvailability() { setError(null); try { logger.info(`Checking availability for track: ${spotifyId}`); - const response = await withTimeout( - CheckTrackAvailability(spotifyId), - CHECK_TIMEOUT_MS, - `Availability check timed out after 10 seconds for ${spotifyId}`, - ); + const response = await withTimeout(CheckTrackAvailability(spotifyId), CHECK_TIMEOUT_MS, `Availability check timed out after 10 seconds for ${spotifyId}`); const availability: TrackAvailability = JSON.parse(response); setAvailabilityMap((prev) => { const newMap = new Map(prev); diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts index d958a81..379e886 100644 --- a/frontend/src/lib/api-status.ts +++ b/frontend/src/lib/api-status.ts @@ -1,15 +1,12 @@ import { CheckAPIStatus } from "../../wailsjs/go/main/App"; import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout"; - export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; - export interface ApiSource { id: string; type: string; name: string; url: string; } - export const API_SOURCES: ApiSource[] = [ { 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" }, @@ -23,32 +20,25 @@ export const API_SOURCES: ApiSource[] = [ { 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" }, ]; - type ApiStatusState = { isCheckingAll: boolean; statuses: Record; }; - let apiStatusState: ApiStatusState = { isCheckingAll: false, statuses: {}, }; - let activeCheckAll: Promise | null = null; - const listeners = new Set<() => void>(); - function emitApiStatusChange() { for (const listener of listeners) { listener(); } } - function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) { apiStatusState = updater(apiStatusState); emitApiStatusChange(); } - async function checkSingleApiStatus(source: ApiSource): Promise { setApiStatusState((current) => ({ ...current, @@ -57,14 +47,8 @@ async function checkSingleApiStatus(source: ApiSource): Promise { [source.id]: "checking", }, })); - try { - const isOnline = await withTimeout( - CheckAPIStatus(source.type, source.url), - CHECK_TIMEOUT_MS, - `API status check timed out after 10 seconds for ${source.url}`, - ); - + const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.url}`); setApiStatusState((current) => ({ ...current, statuses: { @@ -72,7 +56,8 @@ async function checkSingleApiStatus(source: ApiSource): Promise { [source.id]: isOnline ? "online" : "offline", }, })); - } catch { + } + catch { setApiStatusState((current) => ({ ...current, statuses: { @@ -82,45 +67,39 @@ async function checkSingleApiStatus(source: ApiSource): Promise { })); } } - export function getApiStatusState(): ApiStatusState { return apiStatusState; } - export function subscribeApiStatus(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } - export function hasApiStatusResults(): boolean { return API_SOURCES.some((source) => { const status = apiStatusState.statuses[source.id]; return status === "online" || status === "offline"; }); } - export function ensureApiStatusCheckStarted(): void { if (!activeCheckAll && !hasApiStatusResults()) { void checkAllApiStatuses(); } } - export async function checkAllApiStatuses(): Promise { if (activeCheckAll) { return activeCheckAll; } - activeCheckAll = (async () => { setApiStatusState((current) => ({ ...current, isCheckingAll: true, })); - try { await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source))); - } finally { + } + finally { setApiStatusState((current) => ({ ...current, isCheckingAll: false, @@ -128,6 +107,5 @@ export async function checkAllApiStatuses(): Promise { activeCheckAll = null; } })(); - return activeCheckAll; } diff --git a/frontend/src/lib/async-timeout.ts b/frontend/src/lib/async-timeout.ts index 217deb7..5c90ab4 100644 --- a/frontend/src/lib/async-timeout.ts +++ b/frontend/src/lib/async-timeout.ts @@ -1,23 +1,17 @@ export const CHECK_TIMEOUT_MS = 10 * 1000; - -export function withTimeout( - promise: Promise, - timeoutMs: number = CHECK_TIMEOUT_MS, - message: string = `Operation timed out after ${Math.round(timeoutMs / 1000)} seconds`, -): Promise { +export function withTimeout(promise: Promise, timeoutMs: number = CHECK_TIMEOUT_MS, message: string = `Operation timed out after ${Math.round(timeoutMs / 1000)} seconds`): Promise { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => { reject(new Error(message)); }, timeoutMs); - promise .then((value) => { - window.clearTimeout(timer); - resolve(value); - }) + window.clearTimeout(timer); + resolve(value); + }) .catch((error) => { - window.clearTimeout(timer); - reject(error); - }); + window.clearTimeout(timer); + reject(error); + }); }); }