v6.5
This commit is contained in:
@@ -1 +1 @@
|
|||||||
e92e100705a0bb90f6783cd4074df1a7
|
23a60910537eca7800052fa01bf45b7a
|
||||||
+18
-6
@@ -26,6 +26,7 @@ import { PlaylistInfo } from "@/components/PlaylistInfo";
|
|||||||
import { ArtistInfo } from "@/components/ArtistInfo";
|
import { ArtistInfo } from "@/components/ArtistInfo";
|
||||||
import { DownloadQueue } from "@/components/DownloadQueue";
|
import { DownloadQueue } from "@/components/DownloadQueue";
|
||||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||||
|
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||||
import type { HistoryItem } from "@/components/FetchHistory";
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
@@ -38,7 +39,10 @@ import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
|||||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||||
const MAX_HISTORY = 5;
|
const MAX_HISTORY = 5;
|
||||||
|
|
||||||
|
type PageType = "main" | "audio-analysis";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [currentPageView, setCurrentPageView] = useState<PageType>("main");
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -48,7 +52,7 @@ function App() {
|
|||||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "6.4";
|
const CURRENT_VERSION = "6.5";
|
||||||
|
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
@@ -258,6 +262,7 @@ function App() {
|
|||||||
downloadingTrack={download.downloadingTrack}
|
downloadingTrack={download.downloadingTrack}
|
||||||
isDownloaded={download.downloadedTracks.has(track.isrc)}
|
isDownloaded={download.downloadedTracks.has(track.isrc)}
|
||||||
isFailed={download.failedTracks.has(track.isrc)}
|
isFailed={download.failedTracks.has(track.isrc)}
|
||||||
|
isSkipped={download.skippedTracks.has(track.isrc)}
|
||||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||||
downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")}
|
downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")}
|
||||||
failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")}
|
failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")}
|
||||||
@@ -459,10 +464,15 @@ function App() {
|
|||||||
<TitleBar />
|
<TitleBar />
|
||||||
<div className="flex-1 p-4 md:p-8">
|
<div className="flex-1 p-4 md:p-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
<Header
|
{currentPageView === "audio-analysis" ? (
|
||||||
version={CURRENT_VERSION}
|
<AudioAnalysisPage onBack={() => setCurrentPageView("main")} />
|
||||||
hasUpdate={hasUpdate}
|
) : (
|
||||||
/>
|
<>
|
||||||
|
<Header
|
||||||
|
version={CURRENT_VERSION}
|
||||||
|
hasUpdate={hasUpdate}
|
||||||
|
onOpenAudioAnalysis={() => setCurrentPageView("audio-analysis")}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Download Progress Toast - Bottom Left */}
|
{/* Download Progress Toast - Bottom Left */}
|
||||||
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
||||||
@@ -581,7 +591,9 @@ function App() {
|
|||||||
hasResult={!!metadata.metadata}
|
hasResult={!!metadata.metadata}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{metadata.metadata && renderMetadata()}
|
{metadata.metadata && renderMetadata()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Activity, Upload, X } from "lucide-react";
|
|
||||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
|
||||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
|
||||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
|
||||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
|
||||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export function AudioAnalysisDialog() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
|
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
|
||||||
try {
|
|
||||||
const filePath = await SelectFile();
|
|
||||||
if (filePath) {
|
|
||||||
setSelectedFilePath(filePath);
|
|
||||||
await analyzeFile(filePath);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("File Selection Failed", {
|
|
||||||
description: err instanceof Error ? err.message : "Failed to select file",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
|
||||||
setIsDragging(false);
|
|
||||||
|
|
||||||
if (paths.length === 0) return;
|
|
||||||
|
|
||||||
const filePath = paths[0];
|
|
||||||
|
|
||||||
// Check if it's a FLAC file
|
|
||||||
if (!filePath.toLowerCase().endsWith('.flac')) {
|
|
||||||
toast.error("Invalid File Type", {
|
|
||||||
description: "Please drop a FLAC file for analysis",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedFilePath(filePath);
|
|
||||||
await analyzeFile(filePath);
|
|
||||||
}, [analyzeFile]);
|
|
||||||
|
|
||||||
// Register drag and drop handlers when dialog is open
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
OnFileDrop((x, y, paths) => {
|
|
||||||
handleFileDrop(x, y, paths);
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
OnFileDropOff();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [open, handleFileDrop]);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setOpen(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
clearResult();
|
|
||||||
setSelectedFilePath("");
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
handleClose();
|
|
||||||
} else {
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<Activity className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
<p>Audio Quality Analyzer</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto flex flex-col p-6 [&>button]:hidden custom-scrollbar" aria-describedby={undefined}>
|
|
||||||
<div className="absolute right-4 top-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogTitle className="text-sm font-medium">Audio Quality Analyzer</DialogTitle>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* File Selection */}
|
|
||||||
{!result && !analyzing && (
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center justify-center py-12 border-2 border-dashed rounded-lg transition-colors ${
|
|
||||||
isDragging
|
|
||||||
? "border-primary bg-primary/10"
|
|
||||||
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
|
||||||
}`}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
}}
|
|
||||||
onDragLeave={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
}}
|
|
||||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<Activity className={`h-16 w-16 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`} />
|
|
||||||
<h3 className="text-lg font-medium mb-2">Analyze FLAC Audio Quality</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
|
|
||||||
{isDragging
|
|
||||||
? "Drop your FLAC file here"
|
|
||||||
: "Drag and drop a FLAC file here, or click the button below to select"}
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleSelectFile} size="lg">
|
|
||||||
<Upload className="h-5 w-5" />
|
|
||||||
Select FLAC File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Analysis Results */}
|
|
||||||
{result && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* File Info */}
|
|
||||||
<div className="p-3 bg-muted/30 rounded-lg">
|
|
||||||
<p className="text-xs text-muted-foreground">Analyzing file:</p>
|
|
||||||
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spectrum Visualization */}
|
|
||||||
<SpectrumVisualization
|
|
||||||
sampleRate={result.sample_rate}
|
|
||||||
bitsPerSample={result.bits_per_sample}
|
|
||||||
duration={result.duration}
|
|
||||||
spectrumData={result.spectrum}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Detailed Analysis */}
|
|
||||||
<AudioAnalysis
|
|
||||||
result={result}
|
|
||||||
analyzing={analyzing}
|
|
||||||
showAnalyzeButton={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
|
||||||
<Button onClick={handleSelectFile} variant="outline">
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
Analyze Another File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{analyzing && !result && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
|
||||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Activity, Upload, ArrowLeft } from "lucide-react";
|
||||||
|
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||||
|
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||||
|
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||||
|
import { SelectFile } from "../../wailsjs/go/main/App";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
|
|
||||||
|
interface AudioAnalysisPageProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||||
|
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
|
||||||
|
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleSelectFile = async () => {
|
||||||
|
try {
|
||||||
|
const filePath = await SelectFile();
|
||||||
|
if (filePath) {
|
||||||
|
setSelectedFilePath(filePath);
|
||||||
|
await analyzeFile(filePath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("File Selection Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to select file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDrop = useCallback(
|
||||||
|
async (_x: number, _y: number, paths: string[]) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
|
||||||
|
const filePath = paths[0];
|
||||||
|
|
||||||
|
if (!filePath.toLowerCase().endsWith(".flac")) {
|
||||||
|
toast.error("Invalid File Type", {
|
||||||
|
description: "Please drop a FLAC file for analysis",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFilePath(filePath);
|
||||||
|
await analyzeFile(filePath);
|
||||||
|
},
|
||||||
|
[analyzeFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
OnFileDrop((x, y, paths) => {
|
||||||
|
handleFileDrop(x, y, paths);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
OnFileDropOff();
|
||||||
|
};
|
||||||
|
}, [handleFileDrop]);
|
||||||
|
|
||||||
|
const handleAnalyzeAnother = () => {
|
||||||
|
clearResult();
|
||||||
|
setSelectedFilePath("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Analyze FLAC files to verify true lossless quality
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Selection */}
|
||||||
|
{!result && !analyzing && (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center justify-center py-16 border-2 border-dashed rounded-lg transition-colors ${
|
||||||
|
isDragging
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||||
|
}`}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}}
|
||||||
|
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<Activity
|
||||||
|
className={`h-20 w-20 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`}
|
||||||
|
/>
|
||||||
|
<h3 className="text-xl font-medium mb-2">Analyze FLAC Audio Quality</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
|
||||||
|
{isDragging
|
||||||
|
? "Drop your FLAC file here"
|
||||||
|
: "Drag and drop a FLAC file here, or click the button below to select"}
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleSelectFile} size="lg">
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
Select FLAC File
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{analyzing && !result && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
||||||
|
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analysis Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* File Info */}
|
||||||
|
<div className="p-3 bg-muted/30 rounded-lg">
|
||||||
|
<p className="text-xs text-muted-foreground">Analyzing file:</p>
|
||||||
|
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spectrum Visualization */}
|
||||||
|
<SpectrumVisualization
|
||||||
|
sampleRate={result.sample_rate}
|
||||||
|
bitsPerSample={result.bits_per_sample}
|
||||||
|
duration={result.duration}
|
||||||
|
spectrumData={result.spectrum}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Detailed Analysis */}
|
||||||
|
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<Button onClick={handleAnalyzeAnother} variant="outline">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Analyze Another File
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
|
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
|
||||||
import { backend } from "../../wailsjs/go/models";
|
import { backend } from "../../wailsjs/go/models";
|
||||||
@@ -227,26 +227,23 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
{getStatusBadge(item.status)}
|
{getStatusBadge(item.status)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar for downloading items */}
|
{/* Info for downloading items */}
|
||||||
{item.status === "downloading" && (
|
{item.status === "downloading" && (
|
||||||
<div className="space-y-1.5 mt-2">
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
||||||
<div className="flex items-center justify-between text-xs font-mono">
|
<span>
|
||||||
<span>
|
{item.progress > 0
|
||||||
{item.progress > 0
|
? `${item.progress.toFixed(2)} MB`
|
||||||
? `${item.progress.toFixed(2)} MB`
|
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
||||||
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
? "Downloading..."
|
||||||
? "Downloading..."
|
: "Starting..."}
|
||||||
: "Starting..."}
|
</span>
|
||||||
</span>
|
<span>
|
||||||
<span>
|
{item.speed > 0
|
||||||
{item.speed > 0
|
? `${item.speed.toFixed(2)} MB/s`
|
||||||
? `${item.speed.toFixed(2)} MB/s`
|
: queueInfo.current_speed > 0
|
||||||
: queueInfo.current_speed > 0
|
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
: "—"}
|
||||||
: "—"}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={100} className="h-1.5" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Settings } from "@/components/Settings";
|
import { Settings } from "@/components/Settings";
|
||||||
import { AudioAnalysisDialog } from "@/components/AudioAnalysisDialog";
|
import { Activity } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
version: string;
|
version: string;
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
|
onOpenAudioAnalysis: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ version, hasUpdate }: HeaderProps) {
|
export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
@@ -32,14 +34,13 @@ export function Header({ version, hasUpdate }: HeaderProps) {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Badge variant="default" asChild>
|
<Badge variant="default" asChild>
|
||||||
<a
|
<button
|
||||||
href="https://github.com/afkarxyz/SpotiFLAC/releases"
|
type="button"
|
||||||
target="_blank"
|
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
v{version}
|
v{version}
|
||||||
</a>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
{hasUpdate && (
|
{hasUpdate && (
|
||||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||||
@@ -56,24 +57,31 @@ export function Header({ version, hasUpdate }: HeaderProps) {
|
|||||||
<div className="absolute right-0 top-0 flex gap-2">
|
<div className="absolute right-0 top-0 flex gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="outline" size="icon" asChild>
|
<Button
|
||||||
<a
|
variant="outline"
|
||||||
href="https://github.com/afkarxyz/SpotiFLAC/issues"
|
size="icon"
|
||||||
target="_blank"
|
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||||
rel="noopener noreferrer"
|
aria-label="GitHub Issues"
|
||||||
aria-label="GitHub Issues"
|
>
|
||||||
>
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
</svg>
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left">
|
<TooltipContent side="left">
|
||||||
<p>Report bug or request feature</p>
|
<p>Report bug or request feature</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<AudioAnalysisDialog />
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" onClick={onOpenAudioAnalysis}>
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">
|
||||||
|
<p>Audio Quality Analyzer</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Settings />
|
<Settings />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, SkipForward, Globe } from "lucide-react";
|
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -16,6 +16,7 @@ interface TrackInfoProps {
|
|||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloaded: boolean;
|
isDownloaded: boolean;
|
||||||
isFailed: boolean;
|
isFailed: boolean;
|
||||||
|
isSkipped: boolean;
|
||||||
downloadingLyricsTrack?: string | null;
|
downloadingLyricsTrack?: string | null;
|
||||||
downloadedLyrics?: boolean;
|
downloadedLyrics?: boolean;
|
||||||
failedLyrics?: boolean;
|
failedLyrics?: boolean;
|
||||||
@@ -34,6 +35,7 @@ export function TrackInfo({
|
|||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloaded,
|
isDownloaded,
|
||||||
isFailed,
|
isFailed,
|
||||||
|
isSkipped,
|
||||||
downloadingLyricsTrack,
|
downloadingLyricsTrack,
|
||||||
downloadedLyrics,
|
downloadedLyrics,
|
||||||
failedLyrics,
|
failedLyrics,
|
||||||
@@ -57,62 +59,18 @@ export function TrackInfo({
|
|||||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Availability Icons - below cover art */}
|
|
||||||
{availability && (
|
|
||||||
<div className="flex items-center justify-center gap-2 mt-3">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div className={`${availability.tidal ? "text-green-500" : "text-red-500"}`}>
|
|
||||||
<TidalIcon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Tidal</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div className={`${availability.deezer ? "text-green-500" : "text-red-500"}`}>
|
|
||||||
<DeezerIcon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Deezer</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div className={`${availability.amazon ? "text-green-500" : "text-red-500"}`}>
|
|
||||||
<AmazonIcon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Amazon Music</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div className={`${availability.qobuz ? "text-green-500" : "text-red-500"}`}>
|
|
||||||
<QobuzIcon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Qobuz</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-4 min-w-0">
|
<div className="flex-1 space-y-4 min-w-0">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||||
{isDownloaded && (
|
{isSkipped ? (
|
||||||
|
<FileCheck className="h-6 w-6 text-yellow-500 shrink-0" />
|
||||||
|
) : isDownloaded ? (
|
||||||
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
|
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
|
||||||
)}
|
) : isFailed ? (
|
||||||
{isFailed && (
|
|
||||||
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
|
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,13 +109,24 @@ export function TrackInfo({
|
|||||||
>
|
>
|
||||||
{checkingAvailability ? (
|
{checkingAvailability ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
) : availability ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Check Availability</p>
|
{availability ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
|
||||||
|
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "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"}`} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>Check Availability</p>
|
||||||
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -172,7 +141,7 @@ export function TrackInfo({
|
|||||||
{downloadingLyricsTrack === track.spotify_id ? (
|
{downloadingLyricsTrack === track.spotify_id ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : skippedLyrics ? (
|
) : skippedLyrics ? (
|
||||||
<SkipForward className="h-4 w-4 text-yellow-500" />
|
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||||
) : downloadedLyrics ? (
|
) : downloadedLyrics ? (
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
) : failedLyrics ? (
|
) : failedLyrics ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Download, CheckCircle, XCircle, SkipForward, FileText, Globe } from "lucide-react";
|
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -210,7 +210,7 @@ export function TrackList({
|
|||||||
<span className="font-medium">{track.name}</span>
|
<span className="font-medium">{track.name}</span>
|
||||||
)}
|
)}
|
||||||
{skippedTracks.has(track.isrc) ? (
|
{skippedTracks.has(track.isrc) ? (
|
||||||
<SkipForward className="h-4 w-4 text-yellow-500 shrink-0" />
|
<FileCheck className="h-4 w-4 text-yellow-500 shrink-0" />
|
||||||
) : downloadedTracks.has(track.isrc) ? (
|
) : downloadedTracks.has(track.isrc) ? (
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
) : failedTracks.has(track.isrc) ? (
|
) : failedTracks.has(track.isrc) ? (
|
||||||
@@ -350,7 +350,7 @@ export function TrackList({
|
|||||||
{downloadingLyricsTrack === track.spotify_id ? (
|
{downloadingLyricsTrack === track.spotify_id ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : skippedLyrics?.has(track.spotify_id) ? (
|
) : skippedLyrics?.has(track.spotify_id) ? (
|
||||||
<SkipForward className="h-4 w-4 text-yellow-500" />
|
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||||
) : downloadedLyrics?.has(track.spotify_id) ? (
|
) : downloadedLyrics?.has(track.spotify_id) ? (
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
) : failedLyrics?.has(track.spotify_id) ? (
|
) : failedLyrics?.has(track.spotify_id) ? (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
|
|||||||
setHasSelection(start !== end);
|
setHasSelection(start !== end);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check clipboard permission
|
// Check clipboard permission when user explicitly opens the context menu.
|
||||||
const checkClipboard = async () => {
|
const checkClipboard = async () => {
|
||||||
try {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
const text = await navigator.clipboard.readText();
|
||||||
@@ -42,10 +42,6 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
checkClipboard();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCut = async () => {
|
const handleCut = async () => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
@@ -156,7 +152,13 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu onOpenChange={checkClipboard}>
|
<ContextMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
checkClipboard();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime"
|
||||||
import type { Settings } from "./settings";
|
import type { Settings } from "./settings";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
@@ -44,4 +45,15 @@ export function buildOutputPath(settings: Settings, folder?: string) {
|
|||||||
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
||||||
|
|
||||||
return sanitized ? joinPath(os, base, sanitized) : base;
|
return sanitized ? joinPath(os, base, sanitized) : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openExternal(url: string) {
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
BrowserOpenURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "6.2"
|
"version": "6.5"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "6.4"
|
"productVersion": "6.5"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user