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
+1
View File
@@ -58,3 +58,4 @@ temp/
# Build notes (optional - uncomment if you don't want to commit) # Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md # BUILD_NOTES.md
build.txt
+5
View File
@@ -159,6 +159,11 @@ func (a *App) OpenFolder(path string) error {
return nil return nil
} }
// SelectFolder opens a folder selection dialog and returns the selected path
func (a *App) SelectFolder(defaultPath string) (string, error) {
return backend.SelectFolderDialog(a.ctx, defaultPath)
}
// GetDefaults returns the default configuration // GetDefaults returns the default configuration
func (a *App) GetDefaults() map[string]string { func (a *App) GetDefaults() map[string]string {
return map[string]string{ return map[string]string{
+27
View File
@@ -1,8 +1,11 @@
package backend package backend
import ( import (
"context"
"os/exec" "os/exec"
"runtime" "runtime"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
) )
func OpenFolderInExplorer(path string) error { func OpenFolderInExplorer(path string) error {
@@ -21,3 +24,27 @@ func OpenFolderInExplorer(path string) error {
return cmd.Start() return cmd.Start()
} }
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
// If defaultPath is empty, use default music path
if defaultPath == "" {
defaultPath = GetDefaultMusicPath()
}
options := wailsRuntime.OpenDialogOptions{
Title: "Select Download Folder",
DefaultDirectory: defaultPath,
}
selectedPath, err := wailsRuntime.OpenDirectoryDialog(ctx, options)
if err != nil {
return "", err
}
// If user cancelled, selectedPath will be empty
if selectedPath == "" {
return "", nil
}
return selectedPath, nil
}
+181 -41
View File
@@ -19,7 +19,7 @@ import type { SpotifyMetadataResponse, TrackMetadata } from "@/types/api";
import { Settings } from "@/components/Settings"; import { Settings } from "@/components/Settings";
import { getSettings, applyThemeMode } from "@/lib/settings"; import { getSettings, applyThemeMode } from "@/lib/settings";
import { applyTheme } from "@/lib/themes"; 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 { toastWithSound as toast } from "@/lib/toast-with-sound";
import { import {
Pagination, Pagination,
@@ -36,7 +36,15 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { joinPath, sanitizePath } from "./lib/utils"; import { joinPath, sanitizePath } from "./lib/utils";
import { OpenFolder } from "../wailsjs/go/main/App";
function App() { function App() {
const [spotifyUrl, setSpotifyUrl] = useState(""); const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -57,6 +65,7 @@ function App() {
const [hasUpdate, setHasUpdate] = useState(false); const [hasUpdate, setHasUpdate] = useState(false);
const [showAlbumDialog, setShowAlbumDialog] = useState(false); const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{ id: string; name: string; external_urls: string } | null>(null); const [selectedAlbum, setSelectedAlbum] = useState<{ id: string; name: string; external_urls: string } | null>(null);
const [sortBy, setSortBy] = useState<string>("default");
const shouldStopDownloadRef = useRef(false); const shouldStopDownloadRef = useRef(false);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
@@ -104,10 +113,11 @@ function App() {
}; };
useEffect(() => { 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([]); setSelectedTracks([]);
setSearchQuery(""); setSearchQuery("");
setDownloadedTracks(new Set()); setDownloadedTracks(new Set());
setSortBy("default");
setCurrentPage(1); setCurrentPage(1);
}, [metadata]); }, [metadata]);
@@ -485,6 +495,21 @@ function App() {
toast.info('Stopping download...'); 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 = () => { const renderDownloadProgress = () => {
if (!isDownloading) return null; if (!isDownloading) return null;
@@ -497,6 +522,7 @@ function App() {
size="sm" size="sm"
onClick={handleStopDownload} onClick={handleStopDownload}
> >
<StopCircle className="h-4 w-4 mr-2" />
Stop Stop
</Button> </Button>
</div> </div>
@@ -508,7 +534,7 @@ function App() {
}; };
const renderTrackList = (tracks: TrackMetadata[], showCheckboxes: boolean = false, hideAlbumColumn: boolean = false) => { const renderTrackList = (tracks: TrackMetadata[], showCheckboxes: boolean = false, hideAlbumColumn: boolean = false) => {
const filteredTracks = tracks.filter(track => { let filteredTracks = tracks.filter(track => {
if (!searchQuery) return true; if (!searchQuery) return true;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return ( 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 // Pagination
const totalPages = Math.ceil(filteredTracks.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(filteredTracks.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
@@ -676,7 +729,7 @@ function App() {
const { track } = metadata; const { track } = metadata;
return ( return (
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{track.images && ( {track.images && (
<img <img
@@ -726,7 +779,7 @@ function App() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{album_info.images && ( {album_info.images && (
<img <img
@@ -766,6 +819,12 @@ function App() {
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </Button>
)} )}
{downloadedTracks.size > 0 && (
<Button onClick={handleOpenFolder} variant="outline" className="gap-2">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div> </div>
{renderDownloadProgress()} {renderDownloadProgress()}
</div> </div>
@@ -773,14 +832,33 @@ function App() {
</CardContent> </CardContent>
</Card> </Card>
<div className="space-y-4"> <div className="space-y-4">
<div className="relative"> <div className="flex gap-2">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <div className="relative flex-1">
<Input <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
placeholder="Search tracks..." <Input
value={searchQuery} placeholder="Search tracks..."
onChange={(e) => handleSearchChange(e.target.value)} value={searchQuery}
className="pl-10" 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> </div>
{renderTrackList(track_list, true, true)} {renderTrackList(track_list, true, true)}
</div> </div>
@@ -793,7 +871,7 @@ function App() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{playlist_info.owner.images && ( {playlist_info.owner.images && (
<img <img
@@ -833,6 +911,12 @@ function App() {
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </Button>
)} )}
{downloadedTracks.size > 0 && (
<Button onClick={handleOpenFolder} variant="outline" className="gap-2">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div> </div>
{renderDownloadProgress()} {renderDownloadProgress()}
</div> </div>
@@ -840,14 +924,33 @@ function App() {
</CardContent> </CardContent>
</Card> </Card>
<div className="space-y-4"> <div className="space-y-4">
<div className="relative"> <div className="flex gap-2">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <div className="relative flex-1">
<Input <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
placeholder="Search tracks..." <Input
value={searchQuery} placeholder="Search tracks..."
onChange={(e) => handleSearchChange(e.target.value)} value={searchQuery}
className="pl-10" 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> </div>
{renderTrackList(track_list, true)} {renderTrackList(track_list, true)}
</div> </div>
@@ -860,7 +963,7 @@ function App() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{artist_info.images && ( {artist_info.images && (
<img <img
@@ -938,17 +1041,42 @@ function App() {
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </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>
</div> </div>
{renderDownloadProgress()} {renderDownloadProgress()}
<div className="relative"> <div className="flex gap-2">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <div className="relative flex-1">
<Input <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
placeholder="Search tracks..." <Input
value={searchQuery} placeholder="Search tracks..."
onChange={(e) => handleSearchChange(e.target.value)} value={searchQuery}
className="pl-10" 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> </div>
{renderTrackList(track_list, true)} {renderTrackList(track_list, true)}
</div> </div>
@@ -996,8 +1124,8 @@ function App() {
<div className="relative"> <div className="relative">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12" /> <img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()} />
<h1 className="text-4xl font-bold">SpotiFLAC</h1> <h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>SpotiFLAC</h1>
<div className="relative"> <div className="relative">
<Badge variant="default" asChild> <Badge variant="default" asChild>
<a <a
@@ -1117,7 +1245,7 @@ function App() {
</Dialog> </Dialog>
<Card> <Card>
<CardContent className="px-6 space-y-4"> <CardContent className="px-6 py-6 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label> <Label htmlFor="spotify-url">Spotify URL</Label>
@@ -1131,13 +1259,25 @@ function App() {
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <div className="relative flex-1">
id="spotify-url" <Input
placeholder="https://open.spotify.com/..." id="spotify-url"
value={spotifyUrl} placeholder="https://open.spotify.com/..."
onChange={(e) => setSpotifyUrl(e.target.value)} value={spotifyUrl}
onKeyDown={(e) => e.key === "Enter" && handleFetchMetadata()} 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}> <Button onClick={handleFetchMetadata} disabled={loading}>
{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 { Settings as SettingsIcon, FolderOpen, Save, RotateCcw } from "lucide-react";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { OpenFolder } from "../../wailsjs/go/main/App"; import { SelectFolder } from "../../wailsjs/go/main/App";
export function Settings() { export function Settings() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -147,17 +147,19 @@ export function Settings() {
}; };
const handleBrowseFolder = async () => { const handleBrowseFolder = async () => {
if (!tempSettings.downloadPath) {
alert("Please enter a download path first");
return;
}
try { try {
// Call backend to open folder in file explorer // Call backend to open folder selection dialog
await OpenFolder(tempSettings.downloadPath); 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) { } catch (error) {
console.error("Error opening folder:", error); console.error("Error selecting folder:", error);
alert(`Error opening folder: ${error}`); alert(`Error selecting folder: ${error}`);
} }
}; };
@@ -184,8 +186,8 @@ export function Settings() {
placeholder="C:\Users\YourUsername\Music" placeholder="C:\Users\YourUsername\Music"
/> />
<Button type="button" onClick={handleBrowseFolder}> <Button type="button" onClick={handleBrowseFolder}>
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4 mr-2" />
Open Browse
</Button> </Button>
</div> </div>
</div> </div>
+2 -5
View File
@@ -24,17 +24,14 @@ export const DEFAULT_SETTINGS: Settings = {
operatingSystem: "Windows" operatingSystem: "Windows"
}; };
// TODO: add mac/linux defaults
const DEFAULT_PATH : string = "C:\\Users\\Public\\Music";
async function fetchDefaultPath(): Promise<string> { async function fetchDefaultPath(): Promise<string> {
try { try {
const data = await GetDefaults(); const data = await GetDefaults();
return data.downloadPath || DEFAULT_PATH; return data.downloadPath || "";
} catch (error) { } catch (error) {
console.error("Failed to fetch default path:", error); console.error("Failed to fetch default path:", error);
return "";
} }
return DEFAULT_PATH;
} }
const SETTINGS_KEY = "spotiflac-settings"; const SETTINGS_KEY = "spotiflac-settings";