v5.7-beta1

This commit is contained in:
afkarxyz
2025-11-22 11:46:36 +07:00
parent a49bb560bd
commit 10236f00c6
6 changed files with 230 additions and 58 deletions
+181 -41
View File
@@ -19,7 +19,7 @@ import type { SpotifyMetadataResponse, TrackMetadata } from "@/types/api";
import { Settings } from "@/components/Settings";
import { getSettings, applyThemeMode } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { Download, Search, CheckCircle, Info } from "lucide-react";
import { Download, Search, CheckCircle, Info, XCircle, ArrowUpDown, StopCircle, FolderOpen } from "lucide-react";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import {
Pagination,
@@ -36,7 +36,15 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { joinPath, sanitizePath } from "./lib/utils";
import { OpenFolder } from "../wailsjs/go/main/App";
function App() {
const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -57,6 +65,7 @@ function App() {
const [hasUpdate, setHasUpdate] = useState(false);
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{ id: string; name: string; external_urls: string } | null>(null);
const [sortBy, setSortBy] = useState<string>("default");
const shouldStopDownloadRef = useRef(false);
const ITEMS_PER_PAGE = 50;
@@ -104,10 +113,11 @@ function App() {
};
useEffect(() => {
// Clear selection, search, downloaded tracks, and reset page when metadata changes
// Clear selection, search, downloaded tracks, sort, and reset page when metadata changes
setSelectedTracks([]);
setSearchQuery("");
setDownloadedTracks(new Set());
setSortBy("default");
setCurrentPage(1);
}, [metadata]);
@@ -485,6 +495,21 @@ function App() {
toast.info('Stopping download...');
};
const handleOpenFolder = async () => {
const settings = getSettings();
if (!settings.downloadPath) {
toast.error("Download path not set");
return;
}
try {
await OpenFolder(settings.downloadPath);
} catch (error) {
console.error("Error opening folder:", error);
toast.error(`Error opening folder: ${error}`);
}
};
const renderDownloadProgress = () => {
if (!isDownloading) return null;
@@ -497,6 +522,7 @@ function App() {
size="sm"
onClick={handleStopDownload}
>
<StopCircle className="h-4 w-4 mr-2" />
Stop
</Button>
</div>
@@ -508,7 +534,7 @@ function App() {
};
const renderTrackList = (tracks: TrackMetadata[], showCheckboxes: boolean = false, hideAlbumColumn: boolean = false) => {
const filteredTracks = tracks.filter(track => {
let filteredTracks = tracks.filter(track => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
@@ -518,6 +544,33 @@ function App() {
);
});
// Apply sorting
if (sortBy === "title-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
} else if (sortBy === "title-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
} else if (sortBy === "artist-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
} else if (sortBy === "artist-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
} else if (sortBy === "duration-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
} else if (sortBy === "duration-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
} else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
});
} else if (sortBy === "not-downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
});
}
// Pagination
const totalPages = Math.ceil(filteredTracks.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
@@ -676,7 +729,7 @@ function App() {
const { track } = metadata;
return (
<Card>
<CardContent className="pt-6">
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{track.images && (
<img
@@ -726,7 +779,7 @@ function App() {
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{album_info.images && (
<img
@@ -766,6 +819,12 @@ function App() {
Download Selected ({selectedTracks.length})
</Button>
)}
{downloadedTracks.size > 0 && (
<Button onClick={handleOpenFolder} variant="outline" className="gap-2">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div>
{renderDownloadProgress()}
</div>
@@ -773,14 +832,33 @@ function App() {
</CardContent>
</Card>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[200px]">
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
</SelectContent>
</Select>
</div>
{renderTrackList(track_list, true, true)}
</div>
@@ -793,7 +871,7 @@ function App() {
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{playlist_info.owner.images && (
<img
@@ -833,6 +911,12 @@ function App() {
Download Selected ({selectedTracks.length})
</Button>
)}
{downloadedTracks.size > 0 && (
<Button onClick={handleOpenFolder} variant="outline" className="gap-2">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div>
{renderDownloadProgress()}
</div>
@@ -840,14 +924,33 @@ function App() {
</CardContent>
</Card>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[200px]">
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
</SelectContent>
</Select>
</div>
{renderTrackList(track_list, true)}
</div>
@@ -860,7 +963,7 @@ function App() {
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{artist_info.images && (
<img
@@ -938,17 +1041,42 @@ function App() {
Download Selected ({selectedTracks.length})
</Button>
)}
{downloadedTracks.size > 0 && (
<Button onClick={handleOpenFolder} size="sm" variant="outline" className="gap-2">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div>
</div>
{renderDownloadProgress()}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[200px]">
<ArrowUpDown className="h-4 w-4 mr-2" />
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
</SelectContent>
</Select>
</div>
{renderTrackList(track_list, true)}
</div>
@@ -996,8 +1124,8 @@ function App() {
<div className="relative">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3">
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12" />
<h1 className="text-4xl font-bold">SpotiFLAC</h1>
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()} />
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>SpotiFLAC</h1>
<div className="relative">
<Badge variant="default" asChild>
<a
@@ -1117,7 +1245,7 @@ function App() {
</Dialog>
<Card>
<CardContent className="px-6 space-y-4">
<CardContent className="px-6 py-6 space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
@@ -1131,13 +1259,25 @@ function App() {
</Tooltip>
</div>
<div className="flex gap-2">
<Input
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={spotifyUrl}
onChange={(e) => setSpotifyUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleFetchMetadata()}
/>
<div className="relative flex-1">
<Input
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={spotifyUrl}
onChange={(e) => setSpotifyUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleFetchMetadata()}
className="pr-8"
/>
{spotifyUrl && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setSpotifyUrl("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={handleFetchMetadata} disabled={loading}>
{loading ? (
<>
+14 -12
View File
@@ -22,7 +22,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw } from "lucide-react";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { OpenFolder } from "../../wailsjs/go/main/App";
import { SelectFolder } from "../../wailsjs/go/main/App";
export function Settings() {
const [open, setOpen] = useState(false);
@@ -147,17 +147,19 @@ export function Settings() {
};
const handleBrowseFolder = async () => {
if (!tempSettings.downloadPath) {
alert("Please enter a download path first");
return;
}
try {
// Call backend to open folder in file explorer
await OpenFolder(tempSettings.downloadPath);
// Call backend to open folder selection dialog
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
console.log("Selected path:", selectedPath);
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
} else {
console.log("No folder selected or user cancelled");
}
} catch (error) {
console.error("Error opening folder:", error);
alert(`Error opening folder: ${error}`);
console.error("Error selecting folder:", error);
alert(`Error selecting folder: ${error}`);
}
};
@@ -184,8 +186,8 @@ export function Settings() {
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder}>
<FolderOpen className="h-4 w-4" />
Open
<FolderOpen className="h-4 w-4 mr-2" />
Browse
</Button>
</div>
</div>
+2 -5
View File
@@ -24,17 +24,14 @@ export const DEFAULT_SETTINGS: Settings = {
operatingSystem: "Windows"
};
// TODO: add mac/linux defaults
const DEFAULT_PATH : string = "C:\\Users\\Public\\Music";
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
return data.downloadPath || DEFAULT_PATH;
return data.downloadPath || "";
} catch (error) {
console.error("Failed to fetch default path:", error);
return "";
}
return DEFAULT_PATH;
}
const SETTINGS_KEY = "spotiflac-settings";