v7.1.3
This commit is contained in:
@@ -32,6 +32,7 @@ import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useCover } from "@/hooks/useCover";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||
@@ -179,6 +180,7 @@ function App() {
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
checkForUpdates();
|
||||
ensureApiStatusCheckStarted();
|
||||
loadHistory();
|
||||
const handleScroll = () => {
|
||||
setShowScrollTop(window.scrollY > 300);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 345 B |
@@ -15,13 +15,20 @@ 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<Record<string, any>>({});
|
||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||
useEffect(() => {
|
||||
const fetchRepoStats = async () => {
|
||||
const CACHE_KEY = "github_repo_stats_v3";
|
||||
const CACHE_KEY = "github_repo_stats_v4";
|
||||
const CACHE_DURATION = 1000 * 60 * 60;
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
@@ -63,8 +70,10 @@ export function AboutPage() {
|
||||
let totalDownloads = 0;
|
||||
let latestDownloads = 0;
|
||||
let latestVersion = "";
|
||||
let latestReleaseAt = "";
|
||||
if (releases.length > 0) {
|
||||
latestVersion = releases[0].tag_name || "";
|
||||
latestReleaseAt = releases[0].published_at || releases[0].created_at || "";
|
||||
latestDownloads =
|
||||
releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
||||
totalDownloads = releases.reduce((sum: number, release: any) => {
|
||||
@@ -84,6 +93,7 @@ export function AboutPage() {
|
||||
totalDownloads,
|
||||
latestDownloads,
|
||||
latestVersion,
|
||||
latestReleaseAt,
|
||||
languages: topLangs,
|
||||
};
|
||||
}
|
||||
@@ -121,6 +131,39 @@ export function AboutPage() {
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
return `${diffYears}y`;
|
||||
};
|
||||
const formatReleaseTimeAgo = (dateString: string): string => {
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
const now = Date.now();
|
||||
const releasedAt = new Date(dateString).getTime();
|
||||
if (Number.isNaN(releasedAt)) {
|
||||
return "";
|
||||
}
|
||||
const diffMs = Math.max(0, now - releasedAt);
|
||||
const totalMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const totalDays = Math.floor(totalHours / 24);
|
||||
const totalMonths = Math.floor(totalDays / 30);
|
||||
const totalYears = Math.floor(totalMonths / 12);
|
||||
if (totalYears > 0) {
|
||||
const remainingMonths = totalMonths % 12;
|
||||
return remainingMonths > 0 ? `${totalYears}y ${remainingMonths}m ago` : `${totalYears}y ago`;
|
||||
}
|
||||
if (totalMonths > 0) {
|
||||
const remainingDays = totalDays % 30;
|
||||
return remainingDays > 0 ? `${totalMonths}m ${remainingDays}d ago` : `${totalMonths}m ago`;
|
||||
}
|
||||
if (totalDays > 0) {
|
||||
const remainingHours = totalHours % 24;
|
||||
return remainingHours > 0 ? `${totalDays}d ${remainingHours}h ago` : `${totalDays}d ago`;
|
||||
}
|
||||
if (totalHours > 0) {
|
||||
const remainingMinutes = totalMinutes % 60;
|
||||
return `${totalHours}h ${remainingMinutes}m ago`;
|
||||
}
|
||||
return `${totalMinutes}m ago`;
|
||||
};
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000) {
|
||||
return num.toLocaleString();
|
||||
@@ -154,38 +197,72 @@ export function AboutPage() {
|
||||
|
||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||
<div className="grid gap-2 grid-cols-4">
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||
<CardHeader>
|
||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex gap-3 pt-2">
|
||||
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
|
||||
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
|
||||
<img src={XIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X"/>
|
||||
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||
SpotubeDL
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
|
||||
with High Quality.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
||||
<Card className={`gap-2 ${projectCardClass}`} onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["SpotiFLAC-Next"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatReleaseTimeAgo(repoStats["SpotiFLAC-Next"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiFLAC Next
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("SpotiFLAC-Next")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
|
||||
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
|
||||
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
{lang}
|
||||
</span>))}
|
||||
</div>)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||
{formatNumber(repoStats["SpotiFLAC-Next"].stars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||
{repoStats["SpotiFLAC-Next"].forks}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/8 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-sky-700 dark:text-sky-300">
|
||||
<Info className="h-3.5 w-3.5"/>
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
||||
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
|
||||
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiDownloader"].latestVersion}
|
||||
</span>)}
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["SpotiDownloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatReleaseTimeAgo(repoStats["SpotiDownloader"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiDownloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiDownloader
|
||||
@@ -229,63 +306,18 @@ export function AboutPage() {
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className="gap-2 hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiFLAC Next
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("SpotiFLAC-Next")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
|
||||
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
|
||||
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
{lang}
|
||||
</span>))}
|
||||
</div>)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||
{formatNumber(repoStats["SpotiFLAC-Next"].stars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||
{repoStats["SpotiFLAC-Next"].forks}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/8 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-sky-700 dark:text-sky-300">
|
||||
<Info className="h-3.5 w-3.5"/>
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
||||
SpotiFLAC Next is a separate project created as a thank-you
|
||||
to everyone who has supported SpotiFLAC on Ko-fi.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||
</span>)}
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatReleaseTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
Twitter/X Media Batch Downloader
|
||||
@@ -332,6 +364,33 @@ export function AboutPage() {
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||
<CardHeader>
|
||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-2 pt-2">
|
||||
{browserExtensionItems.map((item) => (<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}/>
|
||||
<span className="text-[11px] leading-tight text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>))}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://spotubedl.com/")}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||
SpotubeDL
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
|
||||
with High Quality.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
|
||||
@@ -206,10 +206,16 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
|
||||
@@ -1,59 +1,19 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
interface ApiSource {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
const 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" },
|
||||
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
||||
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
||||
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
||||
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
||||
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
||||
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||
{ 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" },
|
||||
];
|
||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||
export function ApiStatusTab() {
|
||||
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
|
||||
const [isCheckingAll, setIsCheckingAll] = useState(false);
|
||||
const checkStatus = async (sourceId: string, apiType: string, url: string) => {
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: "checking" }));
|
||||
try {
|
||||
const isOnline = await CheckAPIStatus(apiType, url);
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" }));
|
||||
}
|
||||
catch (error) {
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: "offline" }));
|
||||
}
|
||||
};
|
||||
const checkAll = async () => {
|
||||
setIsCheckingAll(true);
|
||||
const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url));
|
||||
await Promise.allSettled(promises);
|
||||
setIsCheckingAll(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
checkAll();
|
||||
}, []);
|
||||
const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus();
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" onClick={checkAll} disabled={isCheckingAll} className="gap-2">
|
||||
<Button variant="outline" onClick={() => void refreshAll()} disabled={isCheckingAll} className="gap-2">
|
||||
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
||||
Refresh All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{SOURCES.map((source) => {
|
||||
{sources.map((source) => {
|
||||
const status = statuses[source.id] || "idle";
|
||||
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -610,10 +610,16 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} size="icon" variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type CSSProperties, type DragEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Upload, ArrowLeft, Trash2, Download, FolderOpen, X, AlertCircle, CheckCircle2, FileMusic, ChevronDown, Play, StopCircle } from "lucide-react";
|
||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||
import { SpectrumVisualization, createSpectrogramDataURL, type SpectrumVisualizationHandle } from "@/components/SpectrumVisualization";
|
||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
|
||||
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;
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
status: BatchItemStatus;
|
||||
error?: string;
|
||||
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",
|
||||
@@ -51,98 +79,458 @@ 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;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
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 = [
|
||||
item.result.file_type ?? "Audio",
|
||||
`${(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...";
|
||||
case "error":
|
||||
return item.error || "Analysis failed";
|
||||
case "pending":
|
||||
default:
|
||||
return "Waiting to be analyzed";
|
||||
}
|
||||
}
|
||||
function statusIcon(status: BatchItemStatus) {
|
||||
switch (status) {
|
||||
case "analyzing":
|
||||
return <Spinner className="h-4 w-4 text-primary"/>;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
|
||||
case "error":
|
||||
return <AlertCircle className="h-4 w-4 text-destructive"/>;
|
||||
case "pending":
|
||||
default:
|
||||
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
|
||||
}
|
||||
}
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis();
|
||||
const { analysisProgress, spectrumLoading, spectrumProgress, analyzeFile, analyzeFilePath, cancelAnalysis, loadStoredAnalysis, clearStoredAnalysis, reAnalyzeSpectrum, clearResult, } = useAudioAnalysis();
|
||||
const [items, setItems] = useState<BatchAnalysisItem[]>([]);
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isExportingSelected, setIsExportingSelected] = useState(false);
|
||||
const [isExportingBatch, setIsExportingBatch] = useState(false);
|
||||
const [isBatchRunning, setIsBatchRunning] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE);
|
||||
const [exportProgress, setExportProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const spectrumRef = useRef<{
|
||||
getCanvasDataURL: () => string | null;
|
||||
}>(null);
|
||||
const analyzeSelectedPath = useCallback(async (filePath: string) => {
|
||||
if (!isSupportedAudioPath(filePath)) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
|
||||
});
|
||||
const spectrumRef = useRef<SpectrumVisualizationHandle>(null);
|
||||
const batchRunIdRef = useRef(0);
|
||||
const itemsRef = useRef(items);
|
||||
const activeItemIdRef = useRef<string | null>(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;
|
||||
}
|
||||
await analyzeFilePath(filePath);
|
||||
}, [analyzeFilePath]);
|
||||
const analyzeSelectedFile = useCallback(async (file: File) => {
|
||||
if (!isSupportedAudioFile(file)) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
|
||||
});
|
||||
loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path);
|
||||
}, [activeItem, loadStoredAnalysis]);
|
||||
const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
await analyzeFile(file);
|
||||
}, [analyzeFile]);
|
||||
const handleSelectFile = useCallback(async () => {
|
||||
const runId = batchRunIdRef.current + 1;
|
||||
batchRunIdRef.current = runId;
|
||||
setIsBatchRunning(true);
|
||||
setBatchProgress({
|
||||
completed: 0,
|
||||
total: entries.length,
|
||||
fileName: entries[0]?.name ?? "",
|
||||
});
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (!filePath) {
|
||||
return;
|
||||
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,
|
||||
displayPath: entry.path,
|
||||
suppressToast: true,
|
||||
})
|
||||
: await analyzeFilePath(entry.path, {
|
||||
analysisKey: entry.id,
|
||||
displayPath: entry.path,
|
||||
suppressToast: true,
|
||||
});
|
||||
if (batchRunIdRef.current !== runId) {
|
||||
return;
|
||||
}
|
||||
if (outcome.cancelled) {
|
||||
return;
|
||||
}
|
||||
if (outcome.result) {
|
||||
const analysisResult = outcome.result;
|
||||
successCount++;
|
||||
setItems((prev) => prev.map((item) => item.id === entry.id
|
||||
? {
|
||||
...item,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
result: analysisResult,
|
||||
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);
|
||||
}
|
||||
}
|
||||
else {
|
||||
failCount++;
|
||||
setItems((prev) => prev.map((item) => item.id === entry.id
|
||||
? {
|
||||
...item,
|
||||
status: "error",
|
||||
error: outcome.error || "Analysis failed",
|
||||
}
|
||||
: item));
|
||||
if (!activeItemIdRef.current) {
|
||||
setActiveSelection(entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await analyzeSelectedPath(filePath);
|
||||
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` : ""}`,
|
||||
});
|
||||
}
|
||||
else if (failCount > 0) {
|
||||
toast.error("Batch Analysis Failed", {
|
||||
description: `All ${failCount} file(s) failed to analyze`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (batchRunIdRef.current === runId) {
|
||||
setIsBatchRunning(false);
|
||||
}
|
||||
}
|
||||
}, [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,
|
||||
source: "path" as const,
|
||||
path,
|
||||
name: fileNameFromPath(path),
|
||||
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,
|
||||
}))
|
||||
.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) {
|
||||
await addPathItems(selectedPaths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
catch {
|
||||
fileInputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
}, [analyzeSelectedPath]);
|
||||
const handleInputChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file)
|
||||
}, [addPathItems, ensureIdleQueue]);
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
await analyzeSelectedFile(file);
|
||||
e.target.value = "";
|
||||
}, [analyzeSelectedFile]);
|
||||
const handleHtmlDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
}
|
||||
try {
|
||||
const selectedFolder = await SelectFolder("");
|
||||
if (!selectedFolder) {
|
||||
return;
|
||||
}
|
||||
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||
if (!folderFiles || folderFiles.length === 0) {
|
||||
toast.info("No audio files found", {
|
||||
description: `No ${SUPPORTED_AUDIO_LABEL} files were found in the selected folder.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await addPathItems(folderFiles.map((file) => file.path));
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Folder Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||
});
|
||||
}
|
||||
}, [addPathItems, ensureIdleQueue]);
|
||||
const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file)
|
||||
const files = Array.from(event.dataTransfer.files ?? []);
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
await analyzeSelectedFile(file);
|
||||
}, [analyzeSelectedFile]);
|
||||
}
|
||||
await addBrowserFiles(files);
|
||||
}, [addBrowserFiles]);
|
||||
useEffect(() => {
|
||||
OnFileDrop((_x, _y, paths) => {
|
||||
setIsDragging(false);
|
||||
const droppedPath = paths?.[0];
|
||||
if (!droppedPath)
|
||||
if (!paths || paths.length === 0) {
|
||||
return;
|
||||
void analyzeSelectedPath(droppedPath);
|
||||
}
|
||||
void addPathItems(paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [analyzeSelectedPath]);
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!spectrumRef.current)
|
||||
return;
|
||||
const dataUrl = spectrumRef.current.getCanvasDataURL();
|
||||
if (!dataUrl) {
|
||||
toast.error("Export Failed", { description: "Cannot get canvas data" });
|
||||
}, [addPathItems]);
|
||||
const handleSelectItem = useCallback((itemId: string) => {
|
||||
setActiveSelection(itemId);
|
||||
}, [setActiveSelection]);
|
||||
const handleRemoveItem = useCallback((itemId: string) => {
|
||||
if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
setIsExporting(true);
|
||||
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);
|
||||
if (!nextActive) {
|
||||
clearResult();
|
||||
}
|
||||
}
|
||||
}, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]);
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (isExportingBatch || isExportingSelected) {
|
||||
return;
|
||||
}
|
||||
batchRunIdRef.current += 1;
|
||||
itemsRef.current = [];
|
||||
setItems([]);
|
||||
setActiveSelection(null);
|
||||
clearStoredAnalysis();
|
||||
clearResult();
|
||||
setIsBatchRunning(false);
|
||||
setBatchProgress(EMPTY_PROGRESS_STATE);
|
||||
setExportProgress(EMPTY_PROGRESS_STATE);
|
||||
setIsDragging(false);
|
||||
}, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]);
|
||||
const handleStopBatch = useCallback(() => {
|
||||
if (!isBatchRunning) {
|
||||
return;
|
||||
}
|
||||
batchRunIdRef.current += 1;
|
||||
cancelAnalysis();
|
||||
setIsBatchRunning(false);
|
||||
setBatchProgress(EMPTY_PROGRESS_STATE);
|
||||
setItems((prev) => prev.map((item) => item.status === "analyzing"
|
||||
? {
|
||||
...item,
|
||||
status: "pending",
|
||||
}
|
||||
: item));
|
||||
toast.info("Batch analysis stopped", {
|
||||
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", {
|
||||
description: "Cannot get canvas data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsExportingSelected(true);
|
||||
try {
|
||||
if (selectedFilePath && isAbsolutePath(selectedFilePath)) {
|
||||
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
|
||||
toast.success("Exported Successfully", {
|
||||
if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) {
|
||||
const outPath = await SaveSpectrumImage(activeItem.path, dataUrl);
|
||||
toast.success("PNG Exported", {
|
||||
description: `Saved to: ${outPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const base = selectedFilePath
|
||||
? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "")
|
||||
: "spectrogram";
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = `${base}_spectrogram.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
toast.success("Exported Successfully", {
|
||||
const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram";
|
||||
downloadDataURL(dataUrl, `${baseName}_spectrogram.png`);
|
||||
toast.success("PNG Exported", {
|
||||
description: "Spectrogram image downloaded",
|
||||
});
|
||||
}
|
||||
@@ -152,42 +540,228 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
});
|
||||
}
|
||||
finally {
|
||||
setIsExporting(false);
|
||||
setIsExportingSelected(false);
|
||||
}
|
||||
}, [selectedFilePath]);
|
||||
const handleAnalyzeAnother = () => {
|
||||
clearResult();
|
||||
};
|
||||
const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined;
|
||||
return (<div className="space-y-6">
|
||||
<input ref={fileInputRef} type="file" accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
|
||||
}, [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({
|
||||
completed: 0,
|
||||
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,
|
||||
sampleRate: result.sample_rate,
|
||||
duration: result.duration,
|
||||
freqScale: preferences.freqScale,
|
||||
colorScheme: preferences.colorScheme,
|
||||
fileName: item.name,
|
||||
});
|
||||
if (item.source === "path" && isAbsolutePath(item.path)) {
|
||||
await SaveSpectrumImage(item.path, dataUrl);
|
||||
}
|
||||
else {
|
||||
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` : ""}`,
|
||||
});
|
||||
}
|
||||
else {
|
||||
toast.error("Batch PNG Export Failed", {
|
||||
description: "No spectrogram PNG files were exported.",
|
||||
});
|
||||
}
|
||||
}
|
||||
finally {
|
||||
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,
|
||||
result: nextResult,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
}
|
||||
: item));
|
||||
}, [activeItem, reAnalyzeSpectrum]);
|
||||
const batchDetailContent = !activeItem ? (<Card>
|
||||
<CardContent className="flex min-h-[320px] items-center justify-center px-6 py-10">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a file from the batch queue to inspect its analysis result.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>) : activeItem.status !== "success" || !activeItem.result ? (<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{activeItem.name}</CardTitle>
|
||||
<p className="break-all font-mono text-sm text-muted-foreground">{activeItem.path}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeItem.status === "analyzing" && (<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner />
|
||||
<span className="text-sm text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
<p className="text-xs text-muted-foreground">{analysisProgress.message}</p>
|
||||
</div>)}
|
||||
{activeItem.status === "pending" && (<p className="text-sm text-muted-foreground">
|
||||
This file is queued and waiting for batch analysis to start.
|
||||
</p>)}
|
||||
{activeItem.status === "error" && (<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{activeItem.error || "Analysis failed"}
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>) : (<div className="space-y-4">
|
||||
<AudioAnalysis result={activeItem.result} analyzing={false} showAnalyzeButton={false} filePath={activeItem.path}/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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>);
|
||||
const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? (<div className="mx-auto w-full max-w-6xl space-y-4">
|
||||
<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="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{activeItem.status === "pending" ? "Preparing..." : "Processing..."}</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
<p className="text-center text-xs text-muted-foreground">{analysisProgress.message}</p>
|
||||
</div>
|
||||
</div>) : (<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">
|
||||
{activeItem.error || "Analysis failed"}
|
||||
</div>
|
||||
</div>);
|
||||
const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result;
|
||||
return (<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 items-center gap-4">
|
||||
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5"/>
|
||||
</Button>)}
|
||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||
</div>
|
||||
{result && (<div className="flex gap-2">
|
||||
<Button onClick={handleExport} variant="outline" size="sm" disabled={isExporting || spectrumLoading}>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isBatchMode && isBatchRunning && (<Button onClick={handleStopBatch} variant="destructive" size="sm" disabled={isExportingBatch || isExportingSelected} className="gap-1.5">
|
||||
<StopCircle className="h-4 w-4"/>
|
||||
Stop
|
||||
</Button>)}
|
||||
{canResumeBatch && (<Button onClick={handleAnalyzePending} variant="outline" size="sm" disabled={isExportingBatch || isExportingSelected || spectrumLoading}>
|
||||
<Play className="h-4 w-4"/>
|
||||
Analyze
|
||||
</Button>)}
|
||||
{isBatchMode && (<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isBatchRunning || isExportingBatch || isExportingSelected}>
|
||||
<Upload className="h-4 w-4 mr-1"/>
|
||||
Add
|
||||
<ChevronDown className="ml-1 h-4 w-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuItem onClick={handleSelectFiles} className="cursor-pointer">
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSelectFolder} className="cursor-pointer">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Add Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>)}
|
||||
{showSingleModeActions && (<Button onClick={handleExportSelected} variant="outline" size="sm" disabled={isExportingSelected || spectrumLoading}>
|
||||
<Download className="h-4 w-4 mr-1"/>
|
||||
{isExporting ? "Exporting..." : "Export PNG"}
|
||||
</Button>
|
||||
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||
{isExportingSelected ? "Exporting..." : "Export PNG"}
|
||||
</Button>)}
|
||||
{isBatchMode && (<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={successItems.length === 0 || isExportingBatch || isExportingSelected || isBatchRunning || spectrumLoading}>
|
||||
<Download className="h-4 w-4 mr-1"/>
|
||||
{isExportingBatch ? "Exporting..." : isExportingSelected ? "Exporting..." : "Export"}
|
||||
<ChevronDown className="ml-1 h-4 w-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
<DropdownMenuItem onClick={handleExportSelected} className="cursor-pointer" disabled={!activeItem?.result?.spectrum}>
|
||||
<Download className="h-4 w-4"/>
|
||||
Export Selected PNG
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleBatchExport} className="cursor-pointer" disabled={successItems.length === 0}>
|
||||
<Download className="h-4 w-4"/>
|
||||
Export All PNG
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>)}
|
||||
{showSingleModeActions && (<Button onClick={handleClearAll} variant="outline" size="sm" disabled={isExportingSelected}>
|
||||
<Trash2 className="h-4 w-4 mr-1"/>
|
||||
Clear
|
||||
</Button>
|
||||
</div>)}
|
||||
</Button>)}
|
||||
{isBatchMode && (<Button onClick={handleClearAll} variant="outline" size="sm" disabled={isExportingBatch || isExportingSelected}>
|
||||
<Trash2 className="h-4 w-4 mr-1"/>
|
||||
Clear
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
{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) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}} onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
}} onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} 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">
|
||||
@@ -195,32 +769,116 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio file here"
|
||||
: "Drag and drop an audio file 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"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Audio File
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Folder
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3, M4A, AAC
|
||||
</p>
|
||||
</div>)}
|
||||
|
||||
{analyzing && !result && (<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>Processing...</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
</div>
|
||||
{isSingleMode && (<div className="space-y-4">
|
||||
{singleModeContent}
|
||||
</div>)}
|
||||
|
||||
{result && (<div className="space-y-4">
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
||||
{isBatchMode && (<div className="grid gap-4 xl:grid-cols-[360px,minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
{(isBatchRunning || isExportingBatch) && (<Card className="gap-2 py-4">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">
|
||||
{isExportingBatch ? "Batch PNG Export" : "Batch Analysis"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="truncate pr-3">
|
||||
{isExportingBatch
|
||||
? exportProgress.fileName || "Preparing export..."
|
||||
: batchProgress.fileName || analysisProgress.message}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{isExportingBatch
|
||||
? `${exportProgress.completed}/${exportProgress.total}`
|
||||
: `${Math.min(batchProgress.completed + (isBatchRunning ? 1 : 0), batchProgress.total)}/${batchProgress.total}`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={isExportingBatch ? exportPercent : batchPercent} className="h-1.5 w-full"/>
|
||||
{!isExportingBatch && (<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{analysisProgress.message}</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>)}
|
||||
|
||||
<SpectrumVisualization ref={spectrumRef} sampleRate={result.sample_rate} duration={result.duration} spectrumData={result.spectrum} fileName={fileName} onReAnalyze={reAnalyzeSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||
<Card className="gap-2 overflow-hidden py-4">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-sm">Batch Queue</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{items.length} queued • {successItems.length} ready
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4">
|
||||
<div className="max-h-[232px] space-y-2 overflow-y-auto pr-1">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeItemId;
|
||||
const isSelectable = item.status !== "pending";
|
||||
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
|
||||
? "border-primary bg-primary/5"
|
||||
: isSelectable
|
||||
? "border-border hover:border-primary/40"
|
||||
: "border-border"}`} onClick={() => {
|
||||
if (!isSelectable) {
|
||||
return;
|
||||
}
|
||||
handleSelectItem(item.id);
|
||||
}} onKeyDown={(event) => {
|
||||
if (!isSelectable) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleSelectItem(item.id);
|
||||
}
|
||||
}}>
|
||||
<div className="mt-0.5 shrink-0">{statusIcon(item.status)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.name}</p>
|
||||
<p className={`truncate text-xs ${item.status === "error" ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{itemMetaLine(item)}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
<span>{fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRemoveItem(item.id);
|
||||
}} disabled={isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{batchDetailContent}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,77 @@
|
||||
export const TidalIcon = ({ className = "w-4 h-4" }: {
|
||||
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;
|
||||
}) => (<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="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>);
|
||||
export const QobuzIcon = ({ className = "w-4 h-4" }: {
|
||||
};
|
||||
function sanitizeClassName(className: string): string {
|
||||
return className
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.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;
|
||||
}) => (<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="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>);
|
||||
export const AmazonIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<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="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>);
|
||||
defaultClassName?: string;
|
||||
}) {
|
||||
const cleanedClassName = sanitizeClassName(className);
|
||||
const statusClasses = getStatusClasses(className);
|
||||
const imageClassName = [
|
||||
cleanedClassName || "w-4 h-4",
|
||||
"inline-block shrink-0 object-contain",
|
||||
!hasRoundedClass(cleanedClassName) ? defaultClassName : "",
|
||||
statusClasses,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return <img src={src} alt={alt} className={imageClassName} loading="lazy" referrerPolicy="no-referrer"/>;
|
||||
}
|
||||
export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.tidal} alt="Tidal" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.qobuz} alt="Qobuz" className={className}/>;
|
||||
}
|
||||
export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.amazon} alt="Amazon Music" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
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="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>;
|
||||
}
|
||||
export function QobuzAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
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="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>;
|
||||
}
|
||||
export function AmazonAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
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="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
@@ -216,10 +216,16 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTypingEffect } from "@/hooks/useTypingEffect";
|
||||
import { getSettings, type Settings } from "@/lib/settings";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
const FETCH_PLACEHOLDERS = [
|
||||
@@ -245,6 +246,7 @@ interface SearchBarProps {
|
||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||
const [showRegionSelector, setShowRegionSelector] = useState(() => getSettings().linkResolver === "songlink");
|
||||
const [resultFilter, setResultFilter] = useState("");
|
||||
const [sortOrders, setSortOrders] = useState<Record<ResultTab, string>>({
|
||||
tracks: "default",
|
||||
@@ -279,6 +281,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
console.error("Failed to load recent searches:", error);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const syncRegionVisibility = (settings?: Partial<Settings>) => {
|
||||
const resolver = settings?.linkResolver ?? getSettings().linkResolver;
|
||||
setShowRegionSelector(resolver === "songlink");
|
||||
};
|
||||
syncRegionVisibility();
|
||||
const handleSettingsUpdate = (event: Event) => {
|
||||
syncRegionVisibility((event as CustomEvent<Partial<Settings>>).detail);
|
||||
};
|
||||
window.addEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
}, []);
|
||||
const saveRecentSearch = (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed)
|
||||
@@ -589,19 +603,19 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</div>
|
||||
|
||||
{!searchMode && (<>
|
||||
<Select value={region} onValueChange={onRegionChange}>
|
||||
<SelectTrigger className="w-[70px] shrink-0">
|
||||
<SelectValue placeholder="Region"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
||||
{r}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({getRegionName(r)})
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showRegionSelector && (<Select value={region} onValueChange={onRegionChange}>
|
||||
<SelectTrigger className="w-[70px] shrink-0">
|
||||
<SelectValue placeholder="Region"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
||||
{r}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({getRegionName(r)})
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
<Button onClick={handleFetchWithValidation} disabled={loading}>
|
||||
{loading ? (<>
|
||||
<Spinner />
|
||||
|
||||
@@ -13,24 +13,9 @@ import { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { ApiStatusTab } from "./ApiStatusTab";
|
||||
const TidalIcon = ({ className }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||
<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>
|
||||
</svg>);
|
||||
const QobuzIcon = ({ className }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||
<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>
|
||||
</svg>);
|
||||
const AmazonIcon = ({ className }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||
<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="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>);
|
||||
import { AmazonIcon, QobuzIcon, TidalIcon } from "./PlatformIcons";
|
||||
import songlinkIcon from "@/assets/icons/songlink.ico";
|
||||
import songstatsIcon from "@/assets/icons/songstats.png";
|
||||
interface SettingsPageProps {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
@@ -247,6 +232,44 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-resolver">Link Resolver</Label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
linkResolver: value,
|
||||
}))}>
|
||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select a link resolver"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="songlink">
|
||||
<span className="flex items-center gap-2">
|
||||
<img src={songlinkIcon} alt="Songlink" className="h-4 w-4 shrink-0 rounded-[3px] object-contain" loading="lazy"/>
|
||||
Songlink
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="songstats">
|
||||
<span className="flex items-center gap-2">
|
||||
<img src={songstatsIcon} alt="Songstats" className="h-4 w-4 shrink-0 rounded-[3px] object-contain" loading="lazy"/>
|
||||
Songstats
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
allowResolverFallback: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
||||
Allow Fallback
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@@ -260,19 +283,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center">
|
||||
<span className="flex items-center gap-2">
|
||||
<TidalIcon />
|
||||
Tidal
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center">
|
||||
<span className="flex items-center gap-2">
|
||||
<QobuzIcon />
|
||||
Qobuz
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon">
|
||||
<span className="flex items-center">
|
||||
<span className="flex items-center gap-2">
|
||||
<AmazonIcon />
|
||||
Amazon Music
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,18 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "
|
||||
export interface SpectrumVisualizationHandle {
|
||||
getCanvasDataURL: () => string | null;
|
||||
}
|
||||
type ColorScheme = AnalyzerColorScheme;
|
||||
type FreqScale = AnalyzerFreqScale;
|
||||
type WindowFunction = AnalyzerWindowFunction;
|
||||
export interface SpectrogramRenderOptions {
|
||||
spectrumData: SpectrumData;
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
freqScale: FreqScale;
|
||||
colorScheme: ColorScheme;
|
||||
fileName?: string;
|
||||
shouldCancel?: () => boolean;
|
||||
}
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
@@ -19,9 +31,6 @@ interface SpectrumVisualizationProps {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
type ColorScheme = AnalyzerColorScheme;
|
||||
type FreqScale = AnalyzerFreqScale;
|
||||
type WindowFunction = AnalyzerWindowFunction;
|
||||
const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 };
|
||||
const CANVAS_W = 1100;
|
||||
const CANVAS_H = 600;
|
||||
@@ -420,6 +429,20 @@ async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: Spectr
|
||||
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
|
||||
drawColorBar(ctx, plotHeight, colorScheme);
|
||||
}
|
||||
export async function renderSpectrogramToCanvas(canvas: HTMLCanvasElement, options: SpectrogramRenderOptions): Promise<void> {
|
||||
canvas.width = CANVAS_W;
|
||||
canvas.height = CANVAS_H;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("Cannot get 2D canvas context");
|
||||
}
|
||||
await renderSpectrogram(ctx, options.spectrumData, options.sampleRate, options.duration, options.freqScale, options.colorScheme, options.fileName, options.shouldCancel ?? (() => false));
|
||||
}
|
||||
export async function createSpectrogramDataURL(options: SpectrogramRenderOptions): Promise<string> {
|
||||
const canvas = document.createElement("canvas");
|
||||
await renderSpectrogramToCanvas(canvas, options);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
const COLOR_SCHEMES: {
|
||||
value: ColorScheme;
|
||||
label: string;
|
||||
@@ -468,7 +491,15 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
||||
let canceled = false;
|
||||
const shouldCancel = () => canceled;
|
||||
if (spectrumData) {
|
||||
void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel);
|
||||
void renderSpectrogramToCanvas(canvas, {
|
||||
spectrumData,
|
||||
sampleRate,
|
||||
duration,
|
||||
freqScale,
|
||||
colorScheme,
|
||||
fileName,
|
||||
shouldCancel,
|
||||
});
|
||||
}
|
||||
else {
|
||||
ctx.fillStyle = "#000000";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Info } from "lucide-react";
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Info, Globe } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getSettings, updateSettings } from "@/lib/settings";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { useState, useEffect } from "react";
|
||||
export function TitleBar() {
|
||||
const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false);
|
||||
@@ -65,6 +66,11 @@ export function TitleBar() {
|
||||
<span>Use SpotFetch API</span>
|
||||
<span className="ml-4">{useSpotFetchAPI ? "✓" : ""}</span>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
|
||||
<Globe className="w-4 h-4 opacity-70"/>
|
||||
<span>Website</span>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe,
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & {
|
||||
@@ -140,16 +140,22 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availability ? (<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
<TidalAvailabilityIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzAvailabilityIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonAvailabilityIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
{isDownloaded && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
@@ -328,9 +328,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
<TidalAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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,
|
||||
refreshAll: checkAllApiStatuses,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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, analyzeSpectrumFromSamples, type AnalysisProgress, } 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 {
|
||||
@@ -49,6 +49,8 @@ let sessionResult: AnalysisResult | null = null;
|
||||
let sessionSelectedFilePath = "";
|
||||
let sessionError: string | null = null;
|
||||
let sessionSamples: Float32Array | null = null;
|
||||
let sessionCurrentAnalysisKey = "";
|
||||
const sessionSamplesByKey = new Map<string, Float32Array>();
|
||||
interface ProgressState {
|
||||
percent: number;
|
||||
message: string;
|
||||
@@ -60,6 +62,35 @@ const DEFAULT_PROGRESS_STATE: ProgressState = {
|
||||
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?: {
|
||||
App?: {
|
||||
ReadFileAsBase64?: (path: string) => Promise<string>;
|
||||
DecodeAudioForAnalysis?: (path: string) => Promise<BackendAnalysisDecodeResponse>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
interface BackendAnalysisDecodeResponse {
|
||||
pcm_base64: string;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
bits_per_sample: number;
|
||||
duration: number;
|
||||
bitrate_kbps?: number;
|
||||
bit_depth?: string;
|
||||
}
|
||||
function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void {
|
||||
if (tokenRef.current) {
|
||||
tokenRef.current.cancelled = true;
|
||||
@@ -81,6 +112,23 @@ function toProgressState(progress: AnalysisProgress): ProgressState {
|
||||
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,
|
||||
channels: decoded.channels > 0 ? decoded.channels : parsed.channels,
|
||||
bitsPerSample,
|
||||
totalSamples: duration > 0 && sampleRate > 0 ? Math.floor(duration * sampleRate) : parsed.totalSamples,
|
||||
duration,
|
||||
bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps,
|
||||
};
|
||||
}
|
||||
export function useAudioAnalysis() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||
@@ -90,6 +138,7 @@ export function useAudioAnalysis() {
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(false);
|
||||
const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||
const samplesRef = useRef<Float32Array | null>(sessionSamples);
|
||||
const currentAnalysisKeyRef = useRef(sessionCurrentAnalysisKey);
|
||||
const analysisTokenRef = useRef<CancelToken | null>(null);
|
||||
const spectrumTokenRef = useRef<CancelToken | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -110,12 +159,32 @@ export function useAudioAnalysis() {
|
||||
sessionError = next;
|
||||
setError(next);
|
||||
}, []);
|
||||
const analyzeFile = useCallback(async (file: File) => {
|
||||
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;
|
||||
sessionSamples = payload.samples;
|
||||
setCurrentAnalysisKey(analysisKey);
|
||||
setResultWithSession(payload.result);
|
||||
setSelectedFilePathWithSession(displayPath);
|
||||
setErrorWithSession(null);
|
||||
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
const analyzeFile = useCallback(async (file: File, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => {
|
||||
if (!file) {
|
||||
setErrorWithSession("No file provided");
|
||||
return null;
|
||||
const errorMessage = "No file provided";
|
||||
setErrorWithSession(errorMessage);
|
||||
return {
|
||||
result: null,
|
||||
error: errorMessage,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
const token = createToken(analysisTokenRef);
|
||||
const analysisKey = options?.analysisKey || file.name;
|
||||
const displayPath = options?.displayPath || file.name;
|
||||
cancelToken(spectrumTokenRef);
|
||||
setAnalyzing(true);
|
||||
setAnalysisProgress({
|
||||
@@ -124,32 +193,44 @@ export function useAudioAnalysis() {
|
||||
});
|
||||
setErrorWithSession(null);
|
||||
setResultWithSession(null);
|
||||
setSelectedFilePathWithSession(file.name);
|
||||
setSelectedFilePathWithSession(displayPath);
|
||||
setCurrentAnalysisKey(analysisKey);
|
||||
try {
|
||||
logger.info(`Analyzing audio file (frontend): ${file.name}`);
|
||||
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,
|
||||
}, (progress) => {
|
||||
if (token.cancelled)
|
||||
if (token.cancelled) {
|
||||
return;
|
||||
}
|
||||
setAnalysisProgress(toProgressState(progress));
|
||||
}, () => token.cancelled);
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
samplesRef.current = payload.samples;
|
||||
sessionSamples = payload.samples;
|
||||
setResultWithSession(payload.result);
|
||||
storeSuccessfulAnalysis(analysisKey, displayPath, payload);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
return payload.result;
|
||||
return {
|
||||
result: payload.result,
|
||||
error: null,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
@@ -158,10 +239,16 @@ export function useAudioAnalysis() {
|
||||
percent: 0,
|
||||
message: "Analysis failed",
|
||||
});
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
if (!options?.suppressToast) {
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
return {
|
||||
result: null,
|
||||
error: errorMessage,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
if (analysisTokenRef.current === token) {
|
||||
@@ -169,13 +256,20 @@ export function useAudioAnalysis() {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
const analyzeFilePath = useCallback(async (filePath: string) => {
|
||||
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]);
|
||||
const analyzeFilePath = useCallback(async (filePath: string, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => {
|
||||
if (!filePath) {
|
||||
setErrorWithSession("No file path provided");
|
||||
return null;
|
||||
const errorMessage = "No file path provided";
|
||||
setErrorWithSession(errorMessage);
|
||||
return {
|
||||
result: null,
|
||||
error: errorMessage,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
const token = createToken(analysisTokenRef);
|
||||
const analysisKey = options?.analysisKey || filePath;
|
||||
const displayPath = options?.displayPath || filePath;
|
||||
cancelToken(spectrumTokenRef);
|
||||
setAnalyzing(true);
|
||||
setAnalysisProgress({
|
||||
@@ -184,18 +278,23 @@ export function useAudioAnalysis() {
|
||||
});
|
||||
setErrorWithSession(null);
|
||||
setResultWithSession(null);
|
||||
setSelectedFilePathWithSession(filePath);
|
||||
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 any)?.go?.main?.App?.ReadFileAsBase64 as ((path: string) => Promise<string>) | undefined;
|
||||
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 null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
setAnalysisProgress({
|
||||
percent: 10,
|
||||
@@ -204,42 +303,105 @@ export function useAudioAnalysis() {
|
||||
const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled);
|
||||
base64Data = "";
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
setAnalysisProgress({
|
||||
percent: 15,
|
||||
message: "Preparing audio buffer...",
|
||||
});
|
||||
const fileName = fileNameFromPath(filePath);
|
||||
const payload = await analyzeAudioArrayBuffer({
|
||||
const input = {
|
||||
fileName,
|
||||
fileSize: arrayBuffer.byteLength,
|
||||
arrayBuffer,
|
||||
}, {
|
||||
};
|
||||
const analysisParams = {
|
||||
fftSize: prefs.fftSize,
|
||||
windowFunction: prefs.windowFunction,
|
||||
}, (progress) => {
|
||||
if (token.cancelled)
|
||||
} 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,
|
||||
});
|
||||
}, () => token.cancelled);
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
};
|
||||
let payload: FrontendAnalysisPayload;
|
||||
try {
|
||||
payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled);
|
||||
}
|
||||
samplesRef.current = payload.samples;
|
||||
sessionSamples = payload.samples;
|
||||
setResultWithSession(payload.result);
|
||||
catch (err) {
|
||||
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,
|
||||
error: null,
|
||||
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,
|
||||
error: null,
|
||||
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);
|
||||
}
|
||||
if (token.cancelled) {
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
storeSuccessfulAnalysis(analysisKey, displayPath, payload);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
return payload.result;
|
||||
return {
|
||||
result: payload.result,
|
||||
error: null,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
@@ -248,10 +410,16 @@ export function useAudioAnalysis() {
|
||||
percent: 0,
|
||||
message: "Analysis failed",
|
||||
});
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
if (!options?.suppressToast) {
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
return {
|
||||
result: null,
|
||||
error: errorMessage,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
if (analysisTokenRef.current === token) {
|
||||
@@ -259,10 +427,46 @@ export function useAudioAnalysis() {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||
if (!result || !samplesRef.current)
|
||||
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]);
|
||||
const loadStoredAnalysis = useCallback((analysisKey: string, nextResult: AnalysisResult, displayPath: string) => {
|
||||
setCurrentAnalysisKey(analysisKey);
|
||||
samplesRef.current = sessionSamplesByKey.get(analysisKey) ?? null;
|
||||
sessionSamples = samplesRef.current;
|
||||
setResultWithSession(nextResult);
|
||||
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);
|
||||
setAnalysisProgress((prev) => prev.percent > 0
|
||||
? {
|
||||
percent: prev.percent,
|
||||
message: "Analysis stopped",
|
||||
}
|
||||
: DEFAULT_PROGRESS_STATE);
|
||||
}, []);
|
||||
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||
if (!result || !samplesRef.current) {
|
||||
return null;
|
||||
}
|
||||
const token = createToken(spectrumTokenRef);
|
||||
setSpectrumLoading(true);
|
||||
setSpectrumProgress({
|
||||
@@ -275,22 +479,24 @@ export function useAudioAnalysis() {
|
||||
fftSize,
|
||||
windowFunction: toWindowFunction(windowFunction),
|
||||
}, (progress) => {
|
||||
if (token.cancelled)
|
||||
if (token.cancelled) {
|
||||
return;
|
||||
}
|
||||
setSpectrumProgress(toProgressState(progress));
|
||||
}, () => token.cancelled);
|
||||
if (token.cancelled) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
setResult((prev) => {
|
||||
const next = prev ? { ...prev, spectrum } : prev;
|
||||
sessionResult = next;
|
||||
return next;
|
||||
});
|
||||
const nextResult = {
|
||||
...result,
|
||||
spectrum,
|
||||
};
|
||||
setResultWithSession(nextResult);
|
||||
return nextResult;
|
||||
}
|
||||
catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
|
||||
logger.error(`Spectrum re-analysis error: ${errorMessage}`);
|
||||
@@ -301,6 +507,7 @@ export function useAudioAnalysis() {
|
||||
toast.error("Spectrum Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
finally {
|
||||
if (spectrumTokenRef.current === token) {
|
||||
@@ -308,7 +515,7 @@ export function useAudioAnalysis() {
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
}
|
||||
}, [result]);
|
||||
}, [result, setResultWithSession]);
|
||||
const clearResult = useCallback(() => {
|
||||
cancelToken(analysisTokenRef);
|
||||
cancelToken(spectrumTokenRef);
|
||||
@@ -319,6 +526,8 @@ export function useAudioAnalysis() {
|
||||
setSpectrumLoading(false);
|
||||
setAnalysisProgress(DEFAULT_PROGRESS_STATE);
|
||||
setSpectrumProgress(DEFAULT_PROGRESS_STATE);
|
||||
currentAnalysisKeyRef.current = "";
|
||||
sessionCurrentAnalysisKey = "";
|
||||
samplesRef.current = null;
|
||||
sessionSamples = null;
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
@@ -332,6 +541,9 @@ export function useAudioAnalysis() {
|
||||
spectrumProgress,
|
||||
analyzeFile,
|
||||
analyzeFilePath,
|
||||
cancelAnalysis,
|
||||
loadStoredAnalysis,
|
||||
clearStoredAnalysis,
|
||||
reAnalyzeSpectrum,
|
||||
clearResult,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from "react";
|
||||
import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
|
||||
import type { TrackAvailability } from "@/types/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||
export function useAvailability() {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
|
||||
@@ -20,7 +21,7 @@ export function useAvailability() {
|
||||
setError(null);
|
||||
try {
|
||||
logger.info(`Checking availability for track: ${spotifyId}`);
|
||||
const response = await CheckTrackAvailability(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);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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" },
|
||||
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
||||
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
||||
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
||||
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
||||
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
||||
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||
{ 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<string, ApiCheckStatus>;
|
||||
};
|
||||
let apiStatusState: ApiStatusState = {
|
||||
isCheckingAll: false,
|
||||
statuses: {},
|
||||
};
|
||||
let activeCheckAll: Promise<void> | 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<void> {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
[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}`);
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
[source.id]: isOnline ? "online" : "offline",
|
||||
},
|
||||
}));
|
||||
}
|
||||
catch {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
[source.id]: "offline",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
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<void> {
|
||||
if (activeCheckAll) {
|
||||
return activeCheckAll;
|
||||
}
|
||||
activeCheckAll = (async () => {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
isCheckingAll: true,
|
||||
}));
|
||||
try {
|
||||
await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source)));
|
||||
}
|
||||
finally {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
isCheckingAll: false,
|
||||
}));
|
||||
activeCheckAll = null;
|
||||
}
|
||||
})();
|
||||
return activeCheckAll;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
reject(new Error(message));
|
||||
}, timeoutMs);
|
||||
promise
|
||||
.then((value) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
window.clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,8 +17,8 @@ const MP4_CONTAINER_TYPES = new Set([
|
||||
"moov", "trak", "mdia", "minf", "stbl", "edts", "dinf",
|
||||
"udta", "ilst", "meta", "stsd", "wave",
|
||||
]);
|
||||
type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC";
|
||||
interface ParsedAudioMetadata {
|
||||
export type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC";
|
||||
export interface ParsedAudioMetadata {
|
||||
fileType: SupportedAudioFileType;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
@@ -417,7 +417,7 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||
}
|
||||
}
|
||||
}
|
||||
else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) {
|
||||
else if ((box.type === "mp4a" || box.type === "aac " || box.type === "alac") && box.offset + 36 <= boxEnd) {
|
||||
channels = view.getUint16(box.offset + 24, false) || channels;
|
||||
bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample;
|
||||
if (!sampleRate) {
|
||||
@@ -455,7 +455,7 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||
duration,
|
||||
};
|
||||
}
|
||||
function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
|
||||
export function parseAudioMetadataFromInput(input: AudioArrayBufferInput): ParsedAudioMetadata {
|
||||
const fileType = detectAudioFileType(input.arrayBuffer, input.fileName);
|
||||
switch (fileType) {
|
||||
case "FLAC": return parseFlacMetadata(input.arrayBuffer);
|
||||
@@ -465,6 +465,15 @@ function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
|
||||
default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`);
|
||||
}
|
||||
}
|
||||
export function pcm16MonoArrayBufferToFloat32Samples(buffer: ArrayBuffer): Float32Array {
|
||||
const sampleCount = Math.floor(buffer.byteLength / 2);
|
||||
const samples = new Float32Array(sampleCount);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
samples[i] = view.getInt16(i * 2, true) / 32768;
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array {
|
||||
const coeffs = new Float32Array(size);
|
||||
if (size <= 1) {
|
||||
@@ -649,7 +658,7 @@ export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFA
|
||||
export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<FrontendAnalysisPayload> {
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "parse", 5, "Parsing audio metadata...");
|
||||
const metadata = parseAudioMetadata(input);
|
||||
const metadata = parseAudioMetadataFromInput(input);
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
|
||||
const audioContext = createAnalysisAudioContext(metadata.sampleRate);
|
||||
@@ -658,70 +667,81 @@ export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, para
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "decode", 35, "Audio decoded");
|
||||
const samples = audioBuffer.getChannelData(0);
|
||||
reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS...");
|
||||
let peak = 0;
|
||||
let sumSquares = 0;
|
||||
let lastMetricsYieldAt = nowMs();
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
throwIfCancelled(shouldCancel);
|
||||
const sample = samples[i];
|
||||
const absSample = Math.abs(sample);
|
||||
if (absSample > peak)
|
||||
peak = absSample;
|
||||
sumSquares += sample * sample;
|
||||
if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) {
|
||||
const metricsProgress = 40 + (((i + 1) / samples.length) * 10);
|
||||
reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS...");
|
||||
const now = nowMs();
|
||||
if (now - lastMetricsYieldAt >= 16) {
|
||||
await nextTick();
|
||||
lastMetricsYieldAt = nowMs();
|
||||
throwIfCancelled(shouldCancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120;
|
||||
const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
|
||||
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
|
||||
const dynamicRange = peakDB - rmsDB;
|
||||
const duration = audioBuffer.duration > 0 ? audioBuffer.duration : metadata.duration;
|
||||
const totalSamples = metadata.totalSamples > 0
|
||||
? metadata.totalSamples
|
||||
: Math.floor(duration * metadata.sampleRate);
|
||||
reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
|
||||
const spectrum = await analyzeSpectrumFromSamples(samples, metadata.sampleRate, params, (progress) => {
|
||||
const mappedPercent = 50 + (progress.percent * 0.45);
|
||||
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
|
||||
}, shouldCancel);
|
||||
reportProgress(onProgress, "finalize", 97, "Finalizing result...");
|
||||
const payload: FrontendAnalysisPayload = {
|
||||
result: {
|
||||
file_path: input.fileName,
|
||||
file_size: input.fileSize,
|
||||
file_type: metadata.fileType,
|
||||
sample_rate: metadata.sampleRate,
|
||||
channels: metadata.channels || audioBuffer.numberOfChannels,
|
||||
bits_per_sample: metadata.bitsPerSample,
|
||||
total_samples: totalSamples,
|
||||
duration,
|
||||
bit_depth: `${metadata.bitsPerSample}-bit`,
|
||||
dynamic_range: dynamicRange,
|
||||
peak_amplitude: peakDB,
|
||||
rms_level: rmsDB,
|
||||
codec_mode: metadata.codecMode,
|
||||
bitrate_kbps: metadata.bitrateKbps,
|
||||
total_frames: metadata.totalFrames,
|
||||
codec_version: metadata.codecVersion,
|
||||
spectrum,
|
||||
},
|
||||
samples,
|
||||
};
|
||||
reportProgress(onProgress, "finalize", 100, "Analysis complete");
|
||||
return payload;
|
||||
return analyzeDecodedSamples(input, metadata, samples, params, onProgress, shouldCancel, audioBuffer.duration);
|
||||
}
|
||||
finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
export async function analyzeDecodedSamples(input: AudioArrayBufferInput, metadata: ParsedAudioMetadata, samples: Float32Array, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck, durationOverride?: number): Promise<FrontendAnalysisPayload> {
|
||||
throwIfCancelled(shouldCancel);
|
||||
const analysisSampleRate = metadata.sampleRate > 0 ? metadata.sampleRate : 44100;
|
||||
const analysisChannels = metadata.channels > 0 ? metadata.channels : 1;
|
||||
const bitDepthLabel = metadata.bitsPerSample > 0 ? `${metadata.bitsPerSample}-bit` : "Unknown";
|
||||
reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS...");
|
||||
let peak = 0;
|
||||
let sumSquares = 0;
|
||||
let lastMetricsYieldAt = nowMs();
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
throwIfCancelled(shouldCancel);
|
||||
const sample = samples[i];
|
||||
const absSample = Math.abs(sample);
|
||||
if (absSample > peak)
|
||||
peak = absSample;
|
||||
sumSquares += sample * sample;
|
||||
if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) {
|
||||
const metricsProgress = 40 + (((i + 1) / Math.max(1, samples.length)) * 10);
|
||||
reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS...");
|
||||
const now = nowMs();
|
||||
if (now - lastMetricsYieldAt >= 16) {
|
||||
await nextTick();
|
||||
lastMetricsYieldAt = nowMs();
|
||||
throwIfCancelled(shouldCancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120;
|
||||
const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
|
||||
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
|
||||
const dynamicRange = peakDB - rmsDB;
|
||||
const duration = durationOverride && durationOverride > 0
|
||||
? durationOverride
|
||||
: (metadata.duration > 0
|
||||
? metadata.duration
|
||||
: (analysisSampleRate > 0 ? samples.length / analysisSampleRate : 0));
|
||||
const totalSamples = metadata.totalSamples > 0
|
||||
? metadata.totalSamples
|
||||
: (duration > 0 ? Math.floor(duration * analysisSampleRate) : samples.length);
|
||||
reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
|
||||
const spectrum = await analyzeSpectrumFromSamples(samples, analysisSampleRate, params, (progress) => {
|
||||
const mappedPercent = 50 + (progress.percent * 0.45);
|
||||
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
|
||||
}, shouldCancel);
|
||||
reportProgress(onProgress, "finalize", 97, "Finalizing result...");
|
||||
const payload: FrontendAnalysisPayload = {
|
||||
result: {
|
||||
file_path: input.fileName,
|
||||
file_size: input.fileSize,
|
||||
file_type: metadata.fileType,
|
||||
sample_rate: analysisSampleRate,
|
||||
channels: analysisChannels,
|
||||
bits_per_sample: metadata.bitsPerSample,
|
||||
total_samples: totalSamples,
|
||||
duration,
|
||||
bit_depth: bitDepthLabel,
|
||||
dynamic_range: dynamicRange,
|
||||
peak_amplitude: peakDB,
|
||||
rms_level: rmsDB,
|
||||
codec_mode: metadata.codecMode,
|
||||
bitrate_kbps: metadata.bitrateKbps,
|
||||
total_frames: metadata.totalFrames,
|
||||
codec_version: metadata.codecVersion,
|
||||
spectrum,
|
||||
},
|
||||
samples,
|
||||
};
|
||||
reportProgress(onProgress, "finalize", 100, "Analysis complete");
|
||||
return payload;
|
||||
}
|
||||
export const analyzeFlacFile = analyzeAudioFile;
|
||||
export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer;
|
||||
|
||||
@@ -5,6 +5,8 @@ export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||
linkResolver: "songstats" | "songlink";
|
||||
allowResolverFallback: boolean;
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
@@ -93,6 +95,8 @@ function detectOS(): "Windows" | "linux/MacOS" {
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
downloadPath: "",
|
||||
downloader: "auto",
|
||||
linkResolver: "songlink",
|
||||
allowResolverFallback: true,
|
||||
theme: "yellow",
|
||||
themeMode: "auto",
|
||||
fontFamily: "google-sans",
|
||||
@@ -225,6 +229,12 @@ function getSettingsFromLocalStorage(): Settings {
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('linkResolver' in parsed)) {
|
||||
parsed.linkResolver = "songlink";
|
||||
}
|
||||
if (!('allowResolverFallback' in parsed)) {
|
||||
parsed.allowResolverFallback = true;
|
||||
}
|
||||
if (!('separator' in parsed)) {
|
||||
parsed.separator = "semicolon";
|
||||
}
|
||||
@@ -304,6 +314,12 @@ export async function loadSettings(): Promise<Settings> {
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('linkResolver' in parsed)) {
|
||||
parsed.linkResolver = "songlink";
|
||||
}
|
||||
if (!('allowResolverFallback' in parsed)) {
|
||||
parsed.allowResolverFallback = true;
|
||||
}
|
||||
if (!('createPlaylistFolder' in parsed)) {
|
||||
parsed.createPlaylistFolder = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user