v5.7-beta1
This commit is contained in:
@@ -58,3 +58,4 @@ temp/
|
||||
|
||||
# Build notes (optional - uncomment if you don't want to commit)
|
||||
# BUILD_NOTES.md
|
||||
build.txt
|
||||
@@ -159,6 +159,11 @@ func (a *App) OpenFolder(path string) error {
|
||||
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
|
||||
func (a *App) GetDefaults() map[string]string {
|
||||
return map[string]string{
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func OpenFolderInExplorer(path string) error {
|
||||
@@ -21,3 +24,27 @@ func OpenFolderInExplorer(path string) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+153
-13
@@ -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,7 +832,8 @@ function App() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<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..."
|
||||
@@ -782,6 +842,24 @@ function App() {
|
||||
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>
|
||||
</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,7 +924,8 @@ function App() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<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..."
|
||||
@@ -849,6 +934,24 @@ function App() {
|
||||
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>
|
||||
</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,10 +1041,17 @@ 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">
|
||||
<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..."
|
||||
@@ -950,6 +1060,24 @@ function App() {
|
||||
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">
|
||||
<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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user