This commit is contained in:
afkarxyz
2025-11-22 06:16:18 +07:00
parent 50ca20ce0f
commit 0c284ba62c
26 changed files with 1042 additions and 261 deletions
+311
View File
@@ -0,0 +1,311 @@
name: Build Multi-Platform
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
workflow_dispatch:
env:
GO_VERSION: '1.25.4'
NODE_VERSION: '20'
jobs:
build-windows:
name: Build Windows
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="dev"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build application
run: wails build -platform windows/amd64
- name: Prepare artifacts
run: |
mkdir -p dist
Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC-${{ steps.version.outputs.version }}.exe"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-portable
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.exe
retention-days: 7
build-macos:
name: Build macOS
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="dev"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build application
run: wails build -platform darwin/universal
- name: Create DMG
run: |
mkdir -p dist
# Install create-dmg if not available
brew install create-dmg || true
# Create DMG
create-dmg \
--volname "SpotiFLAC" \
--window-pos 200 120 \
--window-size 600 400 \
--icon-size 100 \
--icon "SpotiFLAC.app" 175 120 \
--hide-extension "SpotiFLAC.app" \
--app-drop-link 425 120 \
"dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg" \
"build/bin/SpotiFLAC.app" || \
# Fallback to hdiutil if create-dmg fails
hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-portable
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.dmg
retention-days: 7
build-linux:
name: Build Linux
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
VERSION="dev"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libfuse2
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build application
run: wails build -platform linux/amd64
- name: Download appimagetool
run: |
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool
- name: Create AppImage
run: |
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
# Copy binary
cp build/bin/SpotiFLAC AppDir/usr/bin/spotiflac
# Create desktop file
cat > AppDir/usr/share/applications/spotiflac.desktop << 'EOF'
[Desktop Entry]
Name=SpotiFLAC
Exec=spotiflac
Icon=spotiflac
Type=Application
Categories=Audio;AudioVideo;
Comment=Get Spotify tracks in true FLAC from Tidal/Deezer
EOF
# Copy icon if exists
if [ -f "build/appicon.png" ]; then
cp build/appicon.png AppDir/usr/share/icons/hicolor/256x256/apps/spotiflac.png
elif [ -f "appicon.png" ]; then
cp appicon.png AppDir/usr/share/icons/hicolor/256x256/apps/spotiflac.png
fi
# Create AppRun
cat > AppDir/AppRun << 'EOF'
#!/bin/sh
SELF=$(readlink -f "$0")
HERE=${SELF%/*}
export PATH="${HERE}/usr/bin/:${PATH}"
export LD_LIBRARY_PATH="${HERE}/usr/lib/:${LD_LIBRARY_PATH}"
exec "${HERE}/usr/bin/spotiflac" "$@"
EOF
chmod +x AppDir/AppRun
# Create AppImage
mkdir -p dist
ARCH=x86_64 ./appimagetool AppDir dist/SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-portable
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
retention-days: 7
create-release:
name: Create Release
needs: [build-windows, build-macos, build-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Create Release
uses: softprops/action-gh-release@v2
with:
draft: true
prerelease: false
generate_release_notes: false
body: |
## Downloads
- **SpotiFLAC-${{ steps.version.outputs.version }}.exe** - Windows
- **SpotiFLAC-${{ steps.version.outputs.version }}.dmg** - macOS
- **SpotiFLAC-${{ steps.version.outputs.version }}.AppImage** - Linux
files: |
artifacts/windows-portable/*.exe
artifacts/macos-portable/*.dmg
artifacts/linux-portable/*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+16 -9
View File
@@ -34,12 +34,14 @@ type SpotifyMetadataRequest struct {
// DownloadRequest represents the request structure for downloading tracks
type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"`
Query string `json:"query,omitempty"`
ApiURL string `json:"api_url,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
ISRC string `json:"isrc"`
Service string `json:"service"`
Query string `json:"query,omitempty"`
ApiURL string `json:"api_url,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"`
TrackNumber bool `json:"track_number,omitempty"`
}
// DownloadResponse represents the response structure for download operations
@@ -103,6 +105,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
var err error
var filename string
// Set default filename format if not provided
if req.FilenameFormat == "" {
req.FilenameFormat = "title-artist"
}
if req.Service == "tidal" {
searchQuery := req.Query
if searchQuery == "" {
@@ -111,14 +118,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.ApiURL == "" || req.ApiURL == "auto" {
downloader := backend.NewTidalDownloader("")
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat)
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber)
} else {
downloader := backend.NewTidalDownloader(req.ApiURL)
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat)
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber)
}
} else {
downloader := backend.NewDeezerDownloader()
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir)
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber)
if err == nil {
filename = "Downloaded via Deezer"
}
+28 -5
View File
@@ -30,9 +30,9 @@ type DeezerTrack struct {
ID int64 `json:"id"`
} `json:"artist"`
Album struct {
Title string `json:"title"`
ID int64 `json:"id"`
CoverXL string `json:"cover_xl"`
Title string `json:"title"`
ID int64 `json:"id"`
CoverXL string `json:"cover_xl"`
CoverBig string `json:"cover_big"`
} `json:"album"`
Contributors []struct {
@@ -162,7 +162,28 @@ func sanitizeFilename(name string) string {
return sanitized
}
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir string) error {
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool) string {
var filename string
// Build base filename based on format
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled
if includeTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%02d. %s", trackNumber, filename)
}
return filename + ".flac"
}
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool) error {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
track, err := d.GetTrackByISRC(isrc)
@@ -193,7 +214,9 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir string) error {
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(track.Title)
filename := fmt.Sprintf("%s - %s.flac", safeArtist, safeTitle)
// Build filename based on format settings
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber)
filepath := filepath.Join(outputDir, filename)
fmt.Println("Downloading FLAC file...")
+27 -4
View File
@@ -306,7 +306,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return nil
}
func (t *TidalDownloader) Download(query, isrc, outputDir, quality string) (string, error) {
func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("directory error: %w", err)
@@ -344,7 +344,9 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality string) (stri
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
}
outputFilename := filepath.Join(outputDir, fmt.Sprintf("%s - %s.flac", artistName, trackTitle))
// Build filename based on format settings
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber)
outputFilename := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024))
@@ -404,7 +406,7 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality string) (stri
return outputFilename, nil
}
func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality string) (string, error) {
func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool) (string, error) {
apis, err := t.GetAvailableAPIs()
if err != nil {
return "", fmt.Errorf("no APIs available for fallback: %w", err)
@@ -416,7 +418,7 @@ func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality s
fallbackDownloader := NewTidalDownloader(apiURL)
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality)
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber)
if err == nil {
fmt.Printf("✓ Success with: %s\n", apiURL)
return result, nil
@@ -432,3 +434,24 @@ func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality s
return "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
}
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool) string {
var filename string
// Build base filename based on format
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled
if includeTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%02d. %s", trackNumber, filename)
}
return filename + ".flac"
}
+1 -1
View File
@@ -18,8 +18,8 @@
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+1 -1
View File
@@ -1 +1 @@
1c863b339b3c07aabe6b968fcd4e46ab
e00813ca84dd3deaade9854c0df093cd
+27 -22
View File
@@ -29,12 +29,12 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tailwindcss/vite':
specifier: ^4.1.17
version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))
@@ -873,8 +873,8 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-switch@1.2.6':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -886,8 +886,8 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -2709,21 +2709,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.6
'@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/react@19.2.6)
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -2740,6 +2725,26 @@ snapshots:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/react@19.2.6)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/react@19.2.6)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.6)(react@19.2.0)':
dependencies:
react: 19.2.0
+213 -68
View File
@@ -17,10 +17,10 @@ import {
import { fetchSpotifyMetadata, downloadTrack } from "@/lib/api";
import type { SpotifyMetadataResponse, TrackMetadata } from "@/types/api";
import { Settings } from "@/components/Settings";
import { getSettings } from "@/lib/settings";
import { getSettings, applyThemeMode } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { Download, Search, Loader2, CheckCircle } from "lucide-react";
import { toast } from "sonner";
import { Download, Search, CheckCircle, Info } from "lucide-react";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import {
Pagination,
PaginationContent,
@@ -29,6 +29,13 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner";
function App() {
const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -41,27 +48,42 @@ function App() {
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
const [bulkDownloadType, setBulkDownloadType] = useState<'all' | 'selected' | null>(null);
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{ name: string; artists: string } | null>(null);
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
const [timeoutValue, setTimeoutValue] = useState(60);
const [pendingUrl, setPendingUrl] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false);
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{ id: string; name: string; external_urls: string } | null>(null);
const shouldStopDownloadRef = useRef(false);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "5.5";
const CURRENT_VERSION = "5.6";
useEffect(() => {
const settings = getSettings();
if (settings.darkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
// Listen for system theme changes when in auto mode
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
const currentSettings = getSettings();
if (currentSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(currentSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
// Check for updates
checkForUpdates();
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
const checkForUpdates = async () => {
@@ -88,21 +110,50 @@ function App() {
setCurrentPage(1);
}, [metadata]);
const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, folderName?: string) => {
const downloadWithAutoFallback = async (
isrc: string,
settings: any,
trackName?: string,
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean
) => {
let service = settings.downloader;
// Build query for Tidal (title + artist)
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
// Sanitize folder name (remove illegal characters for Windows)
const sanitizedFolderName = folderName
? folderName.replace(/[<>:"/\\|?*]/g, '_').trim()
: undefined;
// Build output directory based on settings
let outputDir = settings.downloadPath;
// Build output directory with folder name if provided
const outputDir = sanitizedFolderName
? `${settings.downloadPath}\\${sanitizedFolderName}`
: settings.downloadPath;
// For playlist or artist discography downloads
if (playlistName) {
const sanitizedPlaylist = playlistName.replace(/[<>:"/\\|?*]/g, '_').trim();
outputDir = `${settings.downloadPath}\\${sanitizedPlaylist}`;
// For artist discography: only use album subfolder (artist is redundant)
if (isArtistDiscography) {
// Only add album subfolder if enabled
if (settings.albumSubfolder && albumName) {
const sanitizedAlbum = albumName.replace(/[<>:"/\\|?*]/g, '_').trim();
outputDir = `${outputDir}\\${sanitizedAlbum}`;
}
} else {
// For playlist: use both artist and album subfolders if enabled
// Add artist subfolder if enabled
if (settings.artistSubfolder && artistName) {
const sanitizedArtist = artistName.replace(/[<>:"/\\|?*]/g, '_').trim();
outputDir = `${outputDir}\\${sanitizedArtist}`;
}
// Add album subfolder if enabled
if (settings.albumSubfolder && albumName) {
const sanitizedAlbum = albumName.replace(/[<>:"/\\|?*]/g, '_').trim();
outputDir = `${outputDir}\\${sanitizedAlbum}`;
}
}
}
// If auto mode, try Tidal first
if (service === "auto") {
@@ -112,6 +163,8 @@ function App() {
service: "tidal",
query,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
});
if (tidalResponse.success) {
@@ -131,6 +184,8 @@ function App() {
service: service as "deezer" | "tidal",
query,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
});
};
@@ -190,7 +245,31 @@ function App() {
}
};
const handleDownloadTrack = async (isrc: string, trackName?: string, artistName?: string) => {
const handleAlbumClick = (album: { id: string; name: string; external_urls: string }) => {
setSelectedAlbum(album);
setShowAlbumDialog(true);
};
const handleConfirmAlbumFetch = async () => {
if (!selectedAlbum) return;
setShowAlbumDialog(false);
setLoading(true);
setMetadata(null);
try {
const data = await fetchSpotifyMetadata(selectedAlbum.external_urls);
setMetadata(data);
toast.success("Album metadata fetched successfully");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata");
} finally {
setLoading(false);
setSelectedAlbum(null);
}
};
const handleDownloadTrack = async (isrc: string, trackName?: string, artistName?: string, albumName?: string) => {
if (!isrc) {
toast.error("No ISRC found for this track");
return;
@@ -200,13 +279,14 @@ function App() {
setDownloadingTrack(isrc);
try {
const response = await downloadWithAutoFallback(isrc, settings, trackName, artistName);
// Single track download - no playlist folder
const response = await downloadWithAutoFallback(isrc, settings, trackName, artistName, albumName, undefined, false);
if (response.success) {
toast.success(response.message);
setDownloadedTracks(prev => new Set(prev).add(isrc));
} else {
toast.error(response.error);
toast.error(response.error || "Download failed");
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Download failed");
@@ -230,18 +310,22 @@ function App() {
let errorCount = 0;
const total = selectedTracks.length;
// Get all tracks and folder name from metadata
// Get all tracks and playlist/album info from metadata
let allTracks: TrackMetadata[] = [];
let folderName: string | undefined;
let playlistName: string | undefined;
let isArtistDiscography = false;
if (metadata && "track_list" in metadata) {
allTracks = metadata.track_list;
// Get folder name from album or playlist
// Get playlist/album name for folder structure
if ("album_info" in metadata) {
folderName = metadata.album_info.name;
playlistName = metadata.album_info.name;
} else if ("playlist_info" in metadata) {
folderName = metadata.playlist_info.owner.name;
playlistName = metadata.playlist_info.owner.name;
} else if ("artist_info" in metadata) {
playlistName = metadata.artist_info.name;
isArtistDiscography = true;
}
}
@@ -257,13 +341,20 @@ function App() {
setDownloadingTrack(isrc); // Show spinner on this track
// Set current download info for progress display
if (track) {
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
}
try {
const response = await downloadWithAutoFallback(
isrc,
settings,
track?.name,
track?.artists,
folderName
track?.album_name,
playlistName,
isArtistDiscography
);
if (response.success) {
@@ -280,6 +371,7 @@ function App() {
}
setDownloadingTrack(null); // Clear spinner
setCurrentDownloadInfo(null); // Clear download info
setIsDownloading(false);
setBulkDownloadType(null);
shouldStopDownloadRef.current = false; // Reset flag
@@ -293,7 +385,7 @@ function App() {
setSelectedTracks([]);
};
const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string) => {
const handleDownloadAll = async (tracks: TrackMetadata[], playlistName?: string, isArtistDiscography?: boolean) => {
const tracksWithIsrc = tracks.filter(track => track.isrc);
if (tracksWithIsrc.length === 0) {
@@ -321,13 +413,18 @@ function App() {
setDownloadingTrack(track.isrc); // Show spinner on this track
// Set current download info for progress display
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try {
const response = await downloadWithAutoFallback(
track.isrc,
settings,
track.name,
track.artists,
folderName
track.album_name,
playlistName,
isArtistDiscography
);
if (response.success) {
@@ -344,6 +441,7 @@ function App() {
}
setDownloadingTrack(null); // Clear spinner
setCurrentDownloadInfo(null); // Clear download info
setIsDownloading(false);
setBulkDownloadType(null);
shouldStopDownloadRef.current = false; // Reset flag
@@ -404,7 +502,7 @@ function App() {
</Button>
</div>
<p className="text-xs text-muted-foreground">
{downloadProgress}% complete ({bulkDownloadType === 'all' ? 'Downloading all tracks' : 'Downloading selected tracks'})
{downloadProgress}% - {currentDownloadInfo ? `${currentDownloadInfo.name} - ${currentDownloadInfo.artists}` : 'Preparing download...'}
</p>
</div>
);
@@ -507,7 +605,7 @@ function App() {
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
) : (
<>
<Download className="h-4 w-4 mr-2" />
@@ -607,7 +705,7 @@ function App() {
<div className="space-y-2">
<Button onClick={() => handleDownloadTrack(track.isrc, track.name, track.artists)} disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
) : (
<>
<Download className="h-4 w-4 mr-2" />
@@ -653,7 +751,7 @@ function App() {
<div className="flex gap-2">
<Button onClick={() => handleDownloadAll(track_list, album_info.name)} className="gap-2" disabled={isDownloading}>
{isDownloading && bulkDownloadType === 'all' ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
@@ -662,7 +760,7 @@ function App() {
{selectedTracks.length > 0 && (
<Button onClick={handleDownloadSelected} variant="secondary" className="gap-2" disabled={isDownloading}>
{isDownloading && bulkDownloadType === 'selected' ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
@@ -720,7 +818,7 @@ function App() {
<div className="flex gap-2">
<Button onClick={() => handleDownloadAll(track_list, playlist_info.owner.name)} className="gap-2" disabled={isDownloading}>
{isDownloading && bulkDownloadType === 'all' ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
@@ -729,7 +827,7 @@ function App() {
{selectedTracks.length > 0 && (
<Button onClick={handleDownloadSelected} variant="secondary" className="gap-2" disabled={isDownloading}>
{isDownloading && bulkDownloadType === 'selected' ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
@@ -794,7 +892,11 @@ function App() {
<h3 className="text-2xl font-bold">Discography</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{album_list.map((album) => (
<div key={album.id} className="group cursor-pointer">
<div
key={album.id}
className="group cursor-pointer"
onClick={() => handleAlbumClick({ id: album.id, name: album.name, external_urls: album.external_urls })}
>
<div className="relative mb-4">
{album.images && (
<img
@@ -819,9 +921,9 @@ function App() {
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">Popular Tracks</h3>
<div className="flex gap-2">
<Button onClick={() => handleDownloadAll(track_list)} size="sm" className="gap-2" disabled={isDownloading}>
<Button onClick={() => handleDownloadAll(track_list, artist_info.name, true)} size="sm" className="gap-2" disabled={isDownloading}>
{isDownloading && bulkDownloadType === 'all' ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
@@ -830,7 +932,7 @@ function App() {
{selectedTracks.length > 0 && (
<Button onClick={handleDownloadSelected} size="sm" variant="secondary" className="gap-2" disabled={isDownloading}>
{isDownloading && bulkDownloadType === 'selected' ? (
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
@@ -889,8 +991,9 @@ function App() {
};
return (
<div className="min-h-screen bg-background p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
<TooltipProvider>
<div className="min-h-screen bg-background p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="relative">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3">
@@ -920,26 +1023,33 @@ function App() {
</p>
</div>
<div className="absolute right-0 top-0 flex gap-2">
<Button
variant="outline"
size="icon"
asChild
>
<a
href="https://github.com/afkarxyz/SpotiFLAC/issues"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub Issues"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="currentColor"
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
asChild
>
<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>
</a>
</Button>
<a
href="https://github.com/afkarxyz/SpotiFLAC/issues"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub Issues"
>
<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"/>
</svg>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Report bug or request feature</p>
</TooltipContent>
</Tooltip>
<Settings />
</div>
</div>
@@ -974,16 +1084,53 @@ function App() {
Cancel
</Button>
<Button onClick={handleConfirmFetch}>
<Search className="h-4 w-4 mr-2" />
Fetch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Album Fetch Dialog */}
<Dialog open={showAlbumDialog} onOpenChange={setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Fetch Album</DialogTitle>
<DialogDescription>
Do you want to fetch metadata for this album?
</DialogDescription>
</DialogHeader>
{selectedAlbum && (
<div className="py-4">
<p className="font-medium">{selectedAlbum.name}</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowAlbumDialog(false)}>
Cancel
</Button>
<Button onClick={handleConfirmAlbumFetch}>
<Search className="h-4 w-4 mr-2" />
Fetch Album
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Card>
<CardContent className="pt-6 space-y-4">
<CardContent className="px-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
<div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Input
id="spotify-url"
@@ -995,7 +1142,7 @@ function App() {
<Button onClick={handleFetchMetadata} disabled={loading}>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<Spinner />
Fetching...
</>
) : (
@@ -1006,16 +1153,14 @@ function App() {
)}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Supports track, album, playlist, and artist URLs
</p>
</div>
</CardContent>
</Card>
{metadata && renderMetadata()}
</div>
</div>
</div>
</TooltipProvider>
);
}
+109 -72
View File
@@ -17,11 +17,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Settings as SettingsIcon, FolderOpen } from "lucide-react";
import { getSettings, getSettingsWithDefaults, saveSettings, type Settings as SettingsType } from "@/lib/settings";
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";
@@ -33,25 +32,47 @@ export function Settings() {
// Apply saved settings
useEffect(() => {
if (savedSettings.darkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
}, [savedSettings.darkMode, savedSettings.theme]);
// Setup listener for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, [savedSettings.themeMode, savedSettings.theme]);
// Apply temp settings for preview when dialog is open
useEffect(() => {
if (open) {
if (tempSettings.darkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
// Setup listener for system theme changes during preview
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (tempSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(tempSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}
}, [open, tempSettings.darkMode, tempSettings.theme]);
}, [open, tempSettings.themeMode, tempSettings.theme]);
useEffect(() => {
// Load settings with defaults from backend on mount
@@ -80,13 +101,19 @@ export function Settings() {
setOpen(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
// Apply default theme mode and theme
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
};
const handleCancel = () => {
// Revert to saved settings
if (savedSettings.darkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
setTempSettings(savedSettings);
@@ -96,11 +123,7 @@ export function Settings() {
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Dialog is closing, revert to saved settings
if (savedSettings.darkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
setTempSettings(savedSettings);
}
@@ -119,8 +142,8 @@ export function Settings() {
setTempSettings((prev) => ({ ...prev, theme: value }));
};
const toggleDarkMode = () => {
setTempSettings((prev) => ({ ...prev, darkMode: !prev.darkMode }));
const handleThemeModeChange = (value: "auto" | "light" | "dark") => {
setTempSettings((prev) => ({ ...prev, themeMode: value }));
};
const handleBrowseFolder = async () => {
@@ -145,11 +168,11 @@ export function Settings() {
<SettingsIcon className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[550px]" aria-describedby={undefined}>
<DialogContent className="sm:max-w-[500px] max-h-[85vh] flex flex-col" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="grid gap-6 py-4">
<div className="grid gap-4 py-2 overflow-y-auto flex-1">
{/* Download Path */}
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
@@ -186,50 +209,34 @@ export function Settings() {
</div>
{/* File Settings */}
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">File Settings</h3>
<div className="space-y-3 pt-3 border-t">
<h3 className="font-medium text-sm">File Settings</h3>
{/* Filename Format */}
<div className="space-y-2">
<Label>Filename Format</Label>
<div className="space-y-1.5">
<Label className="text-sm">Filename Format</Label>
<RadioGroup
value={tempSettings.filenameFormat}
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
className="flex gap-4"
className="flex flex-wrap gap-3"
>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="title-artist" id="title-artist" />
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-xs">Title - Artist</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="artist-title" id="artist-title" />
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-xs">Artist - Title</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="title" id="title" />
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
<Label htmlFor="title" className="cursor-pointer font-normal text-xs">Title</Label>
</div>
</RadioGroup>
</div>
{/* Subfolder Options */}
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2">
<Checkbox
id="artist-subfolder"
checked={tempSettings.artistSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
/>
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder (Playlist)</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="album-subfolder"
checked={tempSettings.albumSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
/>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder (Playlist)</Label>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="track-number"
@@ -238,22 +245,43 @@ export function Settings() {
/>
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="artist-subfolder"
checked={tempSettings.artistSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
/>
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder (Playlist only)</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="album-subfolder"
checked={tempSettings.albumSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
/>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder (Playlist & Discography)</Label>
</div>
</div>
</div>
{/* Dark Mode Toggle */}
<div className="flex items-center justify-between pt-4 border-t">
<Label htmlFor="dark-mode">Dark Mode</Label>
<Switch
id="dark-mode"
checked={tempSettings.darkMode}
onCheckedChange={toggleDarkMode}
/>
{/* Theme Mode Selection */}
<div className="space-y-1.5 pt-3 border-t">
<Label htmlFor="theme-mode" className="text-sm">Theme</Label>
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Selection */}
<div className="space-y-2">
<Label htmlFor="theme">Theme Color</Label>
{/* Theme Color Selection */}
<div className="space-y-1.5">
<Label htmlFor="theme" className="text-sm">Theme Color</Label>
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
@@ -268,11 +296,20 @@ export function Settings() {
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="outline" onClick={handleReset} size="sm" className="gap-1.5">
<RotateCcw className="h-3.5 w-3.5" />
Reset to Default
</Button>
<Button onClick={handleSave}>Save Changes</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel} size="sm">
Cancel
</Button>
<Button onClick={handleSave} size="sm" className="gap-1.5">
<Save className="h-3.5 w-3.5" />
Save Changes
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
+19 -13
View File
@@ -5,17 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
@@ -24,16 +25,21 @@ const badgeVariants = cva(
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
asChild?: boolean
}
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
function Badge({ className, variant, asChild = false, ...props }: BadgeProps) {
const Comp = asChild ? Slot : "div"
return (
<Comp className={cn(badgeVariants({ variant }), className)} {...props} />
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
+22 -19
View File
@@ -1,42 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
}
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
ref={ref}
data-slot="radio-group-item"
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
}
export { RadioGroup, RadioGroupItem }
+4 -4
View File
@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -60,7 +60,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -74,7 +74,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
)}
>
{children}
@@ -107,7 +107,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
+15
View File
@@ -0,0 +1,15 @@
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
-31
View File
@@ -1,31 +0,0 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+107
View File
@@ -0,0 +1,107 @@
// Audio utility for toast notifications using Web Audio API
class AudioManager {
private audioContext: AudioContext | null = null;
private getAudioContext(): AudioContext {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
}
return this.audioContext;
}
// Generate a simple tone using oscillator
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
} catch (error) {
console.error('Error playing audio:', error);
}
}
// Success sound - pleasant ascending tones
playSuccess() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(523.25, 0.08, 'sine', 0.2, now); // C5
// Second tone
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); // E5
// Third tone
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); // G5
}
// Error sound - descending tones
playError() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(392.00, 0.1, 'square', 0.15, now); // G4
// Second tone
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); // E4
}
// Warning sound - alternating tones
playWarning() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); // A4
// Second tone
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); // B4
}
// Info sound - single soft tone
playInfo() {
this.playTone(523.25, 0.15, 'sine', 0.15); // C5
}
// Helper method to play tone at specific time
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
} catch (error) {
console.error('Error playing audio:', error);
}
}
}
// Export singleton instance
export const audioManager = new AudioManager();
// Helper functions for easy use
export const playSuccessSound = () => audioManager.playSuccess();
export const playErrorSound = () => audioManager.playError();
export const playWarningSound = () => audioManager.playWarning();
export const playInfoSound = () => audioManager.playInfo();
+31 -3
View File
@@ -4,18 +4,18 @@ export interface Settings {
downloadPath: string;
downloader: "auto" | "deezer" | "tidal";
theme: string;
darkMode: boolean;
themeMode: "auto" | "light" | "dark";
filenameFormat: "title-artist" | "artist-title" | "title";
artistSubfolder: boolean;
albumSubfolder: boolean;
trackNumber: boolean;
}
const DEFAULT_SETTINGS: Settings = {
export const DEFAULT_SETTINGS: Settings = {
downloadPath: "",
downloader: "auto",
theme: "yellow",
darkMode: true,
themeMode: "auto",
filenameFormat: "title-artist",
artistSubfolder: false,
albumSubfolder: false,
@@ -39,6 +39,11 @@ export function getSettings(): Settings {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Migrate old darkMode to themeMode
if ('darkMode' in parsed && !('themeMode' in parsed)) {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
return { ...DEFAULT_SETTINGS, ...parsed };
}
} catch (error) {
@@ -72,3 +77,26 @@ export function updateSettings(partial: Partial<Settings>): Settings {
saveSettings(updated);
return updated;
}
export async function resetToDefaultSettings(): Promise<Settings> {
const defaultPath = await fetchDefaultPath();
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
saveSettings(defaultSettings);
return defaultSettings;
}
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
if (mode === "auto") {
// Check system preference
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
} else if (mode === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
+31
View File
@@ -0,0 +1,31 @@
import { toast } from 'sonner';
import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound } from './audio';
// Wrapper functions for toast with sound effects
export const toastWithSound = {
success: (message: string, data?: any) => {
playSuccessSound();
return toast.success(message, data);
},
error: (message: string, data?: any) => {
playErrorSound();
return toast.error(message, data);
},
warning: (message: string, data?: any) => {
playWarningSound();
return toast.warning(message, data);
},
info: (message: string, data?: any) => {
playInfoSound();
return toast.info(message, data);
},
// Default toast without specific type
message: (message: string, data?: any) => {
playInfoSound();
return toast(message, data);
},
};
+2
View File
@@ -103,6 +103,8 @@ export interface DownloadRequest {
output_dir?: string;
audio_format?: string;
folder_name?: string;
filename_format?: string;
track_number?: boolean;
}
export interface DownloadResponse {
+2 -2
View File
@@ -20,8 +20,8 @@ func main() {
// Create application with options
err := wails.Run(&options.App{
Title: "SpotiFLAC",
Width: 1280,
Height: 800,
Width: 1024,
Height: 600,
AssetServer: &assetserver.Options{
Assets: assets,
},
+1 -1
View File
@@ -13,7 +13,7 @@
"info": {
"companyName": "afkarxyz",
"productName": "SpotiFLAC",
"productVersion": "5.5",
"productVersion": "5.6",
"copyright": "Copyright © 2025",
"comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
},