From 0c284ba62c8e295b2923b34826ebc2c32588966c Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Sat, 22 Nov 2025 06:16:18 +0700 Subject: [PATCH] v5.6 --- .github/workflows/build.yml | 311 +++++++++++++++++++++ app.go | 25 +- backend/deezer.go | 39 ++- backend/tidal.go | 31 +- frontend/package.json | 2 +- frontend/package.json.md5 | 2 +- frontend/pnpm-lock.yaml | 49 ++-- frontend/src/App.tsx | 283 ++++++++++++++----- frontend/src/components/Settings.tsx | 181 +++++++----- frontend/src/components/ui/badge.tsx | 32 ++- frontend/src/components/ui/checkbox.tsx | 2 + frontend/src/components/ui/dialog.tsx | 2 + frontend/src/components/ui/label.tsx | 2 + frontend/src/components/ui/pagination.tsx | 2 +- frontend/src/components/ui/progress.tsx | 2 + frontend/src/components/ui/radio-group.tsx | 43 +-- frontend/src/components/ui/select.tsx | 8 +- frontend/src/components/ui/spinner.tsx | 15 + frontend/src/components/ui/switch.tsx | 31 -- frontend/src/components/ui/tooltip.tsx | 61 ++++ frontend/src/lib/audio.ts | 107 +++++++ frontend/src/lib/settings.ts | 34 ++- frontend/src/lib/toast-with-sound.ts | 31 ++ frontend/src/types/api.ts | 2 + main.go | 4 +- wails.json | 2 +- 26 files changed, 1042 insertions(+), 261 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 frontend/src/components/ui/spinner.tsx delete mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/lib/audio.ts create mode 100644 frontend/src/lib/toast-with-sound.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0cda0d2 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/app.go b/app.go index d19c7ad..a0343b1 100644 --- a/app.go +++ b/app.go @@ -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" } diff --git a/backend/deezer.go b/backend/deezer.go index ceab07a..2b9eb55 100644 --- a/backend/deezer.go +++ b/backend/deezer.go @@ -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 { @@ -58,7 +58,7 @@ func NewDeezerDownloader() *DeezerDownloader { func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) { url := fmt.Sprintf("https://api.deezer.com/2.0/track/isrc:%s", isrc) - + resp, err := d.client.Get(url) if err != nil { return nil, fmt.Errorf("failed to fetch track: %w", err) @@ -83,7 +83,7 @@ func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) { func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) { url := fmt.Sprintf("https://api.deezmate.com/dl/%d", trackID) - + resp, err := d.client.Get(url) if err != nil { return "", fmt.Errorf("failed to get download URL: %w", err) @@ -162,9 +162,30 @@ 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) if err != nil { return err @@ -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...") diff --git a/backend/tidal.go b/backend/tidal.go index 2e6c7cc..39ebe05 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -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" +} diff --git a/frontend/package.json b/frontend/package.json index 1aed2b3..f41f77e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 4fcb36d..800e711 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -1c863b339b3c07aabe6b968fcd4e46ab \ No newline at end of file +e00813ca84dd3deaade9854c0df093cd \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 38ab8c9..9bd95aa 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3590a1..2e9cfcf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [bulkDownloadType, setBulkDownloadType] = useState<'all' | 'selected' | null>(null); const [downloadedTracks, setDownloadedTracks] = useState>(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() {

- {downloadProgress}% complete ({bulkDownloadType === 'all' ? 'Downloading all tracks' : 'Downloading selected tracks'}) + {downloadProgress}% - {currentDownloadInfo ? `${currentDownloadInfo.name} - ${currentDownloadInfo.artists}` : 'Preparing download...'}

); @@ -507,7 +605,7 @@ function App() { disabled={isDownloading || downloadingTrack === track.isrc} > {downloadingTrack === track.isrc ? ( - + ) : ( <> @@ -607,7 +705,7 @@ function App() {
+ + + + + + + + +

Report bug or request feature

+
+
@@ -974,16 +1084,53 @@ function App() { Cancel + {/* Album Fetch Dialog */} + + + + Fetch Album + + Do you want to fetch metadata for this album? + + + {selectedAlbum && ( +
+

{selectedAlbum.name}

+
+ )} + + + + +
+
+ - +
- +
+ + + + + + +

Supports track, album, playlist, and artist URLs

+
+
+
{loading ? ( <> - + Fetching... ) : ( @@ -1006,16 +1153,14 @@ function App() { )}
-

- Supports track, album, playlist, and artist URLs -

{metadata && renderMetadata()} + - + ); } diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 73f4fcc..8051892 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -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() { - + Settings -
+
{/* Download Path */}
@@ -186,50 +209,34 @@ export function Settings() {
{/* File Settings */} -
-

File Settings

+
+

File Settings

{/* Filename Format */} -
- +
+ setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))} - className="flex gap-4" + className="flex flex-wrap gap-3" > -
+
- +
-
+
- +
-
+
- +
{/* Subfolder Options */} -
-
- setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))} - /> - -
-
- setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))} - /> - -
+
+
+ setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))} + /> + +
+
+ setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))} + /> + +
- {/* Dark Mode Toggle */} -
- - + {/* Theme Mode Selection */} +
+ +
- {/* Theme Selection */} -
- + {/* Theme Color Selection */} +
+
- - - +
+ + +
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index 7dfdb90..fd3a406 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -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, - VariantProps { - asChild?: boolean -} +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" -function Badge({ className, variant, asChild = false, ...props }: BadgeProps) { - const Comp = asChild ? Slot : "div" return ( - + ) } diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx index 0e2a6cd..cb0b07b 100644 --- a/frontend/src/components/ui/checkbox.tsx +++ b/frontend/src/components/ui/checkbox.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import { CheckIcon } from "lucide-react" diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 6cb123b..d9ccec9 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { XIcon } from "lucide-react" diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx index ef7133a..fb5fbc3 100644 --- a/frontend/src/components/ui/label.tsx +++ b/frontend/src/components/ui/label.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import * as LabelPrimitive from "@radix-ui/react-label" diff --git a/frontend/src/components/ui/pagination.tsx b/frontend/src/components/ui/pagination.tsx index 9071769..0d18541 100644 --- a/frontend/src/components/ui/pagination.tsx +++ b/frontend/src/components/ui/pagination.tsx @@ -124,4 +124,4 @@ export { PaginationPrevious, PaginationNext, PaginationEllipsis, -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx index 10af7e6..e7a416c 100644 --- a/frontend/src/components/ui/progress.tsx +++ b/frontend/src/components/ui/progress.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import * as ProgressPrimitive from "@radix-ui/react-progress" diff --git a/frontend/src/components/ui/radio-group.tsx b/frontend/src/components/ui/radio-group.tsx index 43b43b4..4c76966 100644 --- a/frontend/src/components/ui/radio-group.tsx +++ b/frontend/src/components/ui/radio-group.tsx @@ -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, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { return ( ) -}) -RadioGroup.displayName = RadioGroupPrimitive.Root.displayName +} -const RadioGroupItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { return ( - - + + ) -}) -RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName +} -export { RadioGroup, RadioGroupItem } +export { RadioGroup, RadioGroupItem } \ No newline at end of file diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index d34798f..8e796db 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -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({ {children} @@ -107,7 +107,7 @@ function SelectItem({ ) { + return ( + + ) +} + +export { Spinner } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx deleted file mode 100644 index 6a2b524..0000000 --- a/frontend/src/components/ui/switch.tsx +++ /dev/null @@ -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) { - return ( - - - - ) -} - -export { Switch } diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..1449244 --- /dev/null +++ b/frontend/src/components/ui/tooltip.tsx @@ -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) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/frontend/src/lib/audio.ts b/frontend/src/lib/audio.ts new file mode 100644 index 0000000..d7c7833 --- /dev/null +++ b/frontend/src/lib/audio.ts @@ -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(); diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index ef87a70..678cdcd 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -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 { saveSettings(updated); return updated; } + +export async function resetToDefaultSettings(): Promise { + 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"); + } +} diff --git a/frontend/src/lib/toast-with-sound.ts b/frontend/src/lib/toast-with-sound.ts new file mode 100644 index 0000000..63a043e --- /dev/null +++ b/frontend/src/lib/toast-with-sound.ts @@ -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); + }, +}; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index c09a425..d1ab947 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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 { diff --git a/main.go b/main.go index e2adf6a..e6fce0a 100644 --- a/main.go +++ b/main.go @@ -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, }, diff --git a/wails.json b/wails.json index f16dc65..5883761 100644 --- a/wails.json +++ b/wails.json @@ -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." },