Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78afdbf77f | |||
| 68e2699941 | |||
| cb6515898a | |||
| 5fcd1f2f75 | |||
| ba732f03b0 | |||
| 427fd33a41 | |||
| b4204a3343 | |||
| 9107f9a5fd | |||
| 0c284ba62c | |||
| 50ca20ce0f |
@@ -0,0 +1,345 @@
|
|||||||
|
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: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
pnpm install
|
||||||
|
pnpm run generate-icon
|
||||||
|
|
||||||
|
- 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: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
pnpm install
|
||||||
|
pnpm run generate-icon
|
||||||
|
|
||||||
|
- 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-24.04
|
||||||
|
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.1-dev libfuse2 imagemagick
|
||||||
|
|
||||||
|
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
|
||||||
|
sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
|
||||||
|
|
||||||
|
- name: Install Wails CLI
|
||||||
|
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
pnpm install
|
||||||
|
pnpm run generate-icon
|
||||||
|
|
||||||
|
- 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/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 desktop file to usr/share/applications
|
||||||
|
cp AppDir/spotiflac.desktop AppDir/usr/share/applications/
|
||||||
|
|
||||||
|
# Use existing icon from build or convert SVG to PNG
|
||||||
|
if [ -f "build/appicon.png" ]; then
|
||||||
|
# Resize to 256x256 if needed
|
||||||
|
convert build/appicon.png -resize 256x256 AppDir/spotiflac.png
|
||||||
|
elif [ -f "frontend/public/icon.svg" ]; then
|
||||||
|
# Convert SVG to PNG
|
||||||
|
convert -background none -size 256x256 frontend/public/icon.svg AppDir/spotiflac.png
|
||||||
|
else
|
||||||
|
# Fallback: create simple icon
|
||||||
|
convert -size 256x256 radial-gradient:#FFD700-#FFA500 AppDir/spotiflac.png
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp AppDir/spotiflac.png AppDir/usr/share/icons/hicolor/256x256/apps/
|
||||||
|
cp AppDir/spotiflac.png AppDir/.DirIcon
|
||||||
|
|
||||||
|
# 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 --no-appstream 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: |
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
## 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 }}
|
||||||
@@ -3,14 +3,19 @@
|
|||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/latest/download/SpotiFLAC.exe)
|
Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Lossless Audio Check
|
## Lossless Audio Check
|
||||||
|
|
||||||
@@ -18,4 +23,4 @@ Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) FLAC Checker
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip)
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ type SpotifyMetadataRequest struct {
|
|||||||
|
|
||||||
// DownloadRequest represents the request structure for downloading tracks
|
// DownloadRequest represents the request structure for downloading tracks
|
||||||
type DownloadRequest struct {
|
type DownloadRequest struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
ApiURL string `json:"api_url,omitempty"`
|
ApiURL string `json:"api_url,omitempty"`
|
||||||
OutputDir string `json:"output_dir,omitempty"`
|
OutputDir string `json:"output_dir,omitempty"`
|
||||||
AudioFormat string `json:"audio_format,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
|
// DownloadResponse represents the response structure for download operations
|
||||||
@@ -103,6 +105,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
var err error
|
var err error
|
||||||
var filename string
|
var filename string
|
||||||
|
|
||||||
|
// Set default filename format if not provided
|
||||||
|
if req.FilenameFormat == "" {
|
||||||
|
req.FilenameFormat = "title-artist"
|
||||||
|
}
|
||||||
|
|
||||||
if req.Service == "tidal" {
|
if req.Service == "tidal" {
|
||||||
searchQuery := req.Query
|
searchQuery := req.Query
|
||||||
if searchQuery == "" {
|
if searchQuery == "" {
|
||||||
@@ -111,14 +118,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
|
|
||||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||||
downloader := backend.NewTidalDownloader("")
|
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 {
|
} else {
|
||||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
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 {
|
} else {
|
||||||
downloader := backend.NewDeezerDownloader()
|
downloader := backend.NewDeezerDownloader()
|
||||||
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir)
|
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
filename = "Downloaded via Deezer"
|
filename = "Downloaded via Deezer"
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-8
@@ -30,9 +30,9 @@ type DeezerTrack struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
} `json:"artist"`
|
} `json:"artist"`
|
||||||
Album struct {
|
Album struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
} `json:"album"`
|
} `json:"album"`
|
||||||
Contributors []struct {
|
Contributors []struct {
|
||||||
@@ -58,7 +58,7 @@ func NewDeezerDownloader() *DeezerDownloader {
|
|||||||
|
|
||||||
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
|
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
|
||||||
url := fmt.Sprintf("https://api.deezer.com/2.0/track/isrc:%s", isrc)
|
url := fmt.Sprintf("https://api.deezer.com/2.0/track/isrc:%s", isrc)
|
||||||
|
|
||||||
resp, err := d.client.Get(url)
|
resp, err := d.client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
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) {
|
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
|
||||||
url := fmt.Sprintf("https://api.deezmate.com/dl/%d", trackID)
|
url := fmt.Sprintf("https://api.deezmate.com/dl/%d", trackID)
|
||||||
|
|
||||||
resp, err := d.client.Get(url)
|
resp, err := d.client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -162,9 +162,30 @@ func sanitizeFilename(name string) string {
|
|||||||
return sanitized
|
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)
|
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||||
|
|
||||||
track, err := d.GetTrackByISRC(isrc)
|
track, err := d.GetTrackByISRC(isrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -193,7 +214,9 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir string) error {
|
|||||||
|
|
||||||
safeArtist := sanitizeFilename(artists)
|
safeArtist := sanitizeFilename(artists)
|
||||||
safeTitle := sanitizeFilename(track.Title)
|
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)
|
filepath := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
fmt.Println("Downloading FLAC file...")
|
fmt.Println("Downloading FLAC file...")
|
||||||
|
|||||||
+27
-4
@@ -306,7 +306,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
|||||||
return nil
|
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 outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("directory error: %w", err)
|
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)
|
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 {
|
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))
|
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
|
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()
|
apis, err := t.GetAvailableAPIs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
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)
|
fallbackDownloader := NewTidalDownloader(apiURL)
|
||||||
|
|
||||||
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality)
|
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||||
return result, nil
|
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)
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1c863b339b3c07aabe6b968fcd4e46ab
|
e00813ca84dd3deaade9854c0df093cd
|
||||||
Generated
+27
-22
@@ -29,12 +29,12 @@ importers:
|
|||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(@types/react@19.2.6)(react@19.2.0)
|
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':
|
'@radix-ui/react-tabs':
|
||||||
specifier: ^1.1.13
|
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)
|
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':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.17
|
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))
|
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':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-switch@1.2.6':
|
'@radix-ui/react-tabs@1.1.13':
|
||||||
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': '*'
|
'@types/react': '*'
|
||||||
'@types/react-dom': '*'
|
'@types/react-dom': '*'
|
||||||
@@ -886,8 +886,8 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-tabs@1.1.13':
|
'@radix-ui/react-tooltip@1.2.8':
|
||||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': '*'
|
'@types/react': '*'
|
||||||
'@types/react-dom': '*'
|
'@types/react-dom': '*'
|
||||||
@@ -2709,21 +2709,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.6
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -2740,6 +2725,26 @@ snapshots:
|
|||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
'@types/react-dom': 19.2.3(@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)':
|
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.6)(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|||||||
+214
-69
@@ -17,10 +17,10 @@ import {
|
|||||||
import { fetchSpotifyMetadata, downloadTrack } from "@/lib/api";
|
import { fetchSpotifyMetadata, downloadTrack } from "@/lib/api";
|
||||||
import type { SpotifyMetadataResponse, TrackMetadata } from "@/types/api";
|
import type { SpotifyMetadataResponse, TrackMetadata } from "@/types/api";
|
||||||
import { Settings } from "@/components/Settings";
|
import { Settings } from "@/components/Settings";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings, applyThemeMode } from "@/lib/settings";
|
||||||
import { applyTheme } from "@/lib/themes";
|
import { applyTheme } from "@/lib/themes";
|
||||||
import { Download, Search, Loader2, CheckCircle } from "lucide-react";
|
import { Download, Search, CheckCircle, Info } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
@@ -29,6 +29,13 @@ import {
|
|||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from "@/components/ui/pagination";
|
} from "@/components/ui/pagination";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
@@ -41,27 +48,42 @@ function App() {
|
|||||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||||
const [bulkDownloadType, setBulkDownloadType] = useState<'all' | 'selected' | null>(null);
|
const [bulkDownloadType, setBulkDownloadType] = useState<'all' | 'selected' | null>(null);
|
||||||
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
||||||
|
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{ name: string; artists: string } | null>(null);
|
||||||
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
||||||
const [timeoutValue, setTimeoutValue] = useState(60);
|
const [timeoutValue, setTimeoutValue] = useState(60);
|
||||||
const [pendingUrl, setPendingUrl] = useState("");
|
const [pendingUrl, setPendingUrl] = useState("");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [hasUpdate, setHasUpdate] = useState(false);
|
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 shouldStopDownloadRef = useRef(false);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "5.5";
|
const CURRENT_VERSION = "5.6";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
if (settings.darkMode) {
|
applyThemeMode(settings.themeMode);
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
}
|
|
||||||
applyTheme(settings.theme);
|
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
|
// Check for updates
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
@@ -88,21 +110,50 @@ function App() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [metadata]);
|
}, [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;
|
let service = settings.downloader;
|
||||||
|
|
||||||
// Build query for Tidal (title + artist)
|
// Build query for Tidal (title + artist)
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||||
|
|
||||||
// Sanitize folder name (remove illegal characters for Windows)
|
// Build output directory based on settings
|
||||||
const sanitizedFolderName = folderName
|
let outputDir = settings.downloadPath;
|
||||||
? folderName.replace(/[<>:"/\\|?*]/g, '_').trim()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Build output directory with folder name if provided
|
// For playlist or artist discography downloads
|
||||||
const outputDir = sanitizedFolderName
|
if (playlistName) {
|
||||||
? `${settings.downloadPath}\\${sanitizedFolderName}`
|
const sanitizedPlaylist = playlistName.replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||||
: settings.downloadPath;
|
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 auto mode, try Tidal first
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
@@ -112,6 +163,8 @@ function App() {
|
|||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameFormat,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tidalResponse.success) {
|
if (tidalResponse.success) {
|
||||||
@@ -131,6 +184,8 @@ function App() {
|
|||||||
service: service as "deezer" | "tidal",
|
service: service as "deezer" | "tidal",
|
||||||
query,
|
query,
|
||||||
output_dir: outputDir,
|
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) {
|
if (!isrc) {
|
||||||
toast.error("No ISRC found for this track");
|
toast.error("No ISRC found for this track");
|
||||||
return;
|
return;
|
||||||
@@ -200,13 +279,14 @@ function App() {
|
|||||||
setDownloadingTrack(isrc);
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
try {
|
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) {
|
if (response.success) {
|
||||||
toast.success(response.message);
|
toast.success(response.message);
|
||||||
setDownloadedTracks(prev => new Set(prev).add(isrc));
|
setDownloadedTracks(prev => new Set(prev).add(isrc));
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error);
|
toast.error(response.error || "Download failed");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Download failed");
|
toast.error(err instanceof Error ? err.message : "Download failed");
|
||||||
@@ -230,18 +310,22 @@ function App() {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
const total = selectedTracks.length;
|
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 allTracks: TrackMetadata[] = [];
|
||||||
let folderName: string | undefined;
|
let playlistName: string | undefined;
|
||||||
|
let isArtistDiscography = false;
|
||||||
|
|
||||||
if (metadata && "track_list" in metadata) {
|
if (metadata && "track_list" in metadata) {
|
||||||
allTracks = metadata.track_list;
|
allTracks = metadata.track_list;
|
||||||
|
|
||||||
// Get folder name from album or playlist
|
// Get playlist/album name for folder structure
|
||||||
if ("album_info" in metadata) {
|
if ("album_info" in metadata) {
|
||||||
folderName = metadata.album_info.name;
|
playlistName = metadata.album_info.name;
|
||||||
} else if ("playlist_info" in metadata) {
|
} 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
|
setDownloadingTrack(isrc); // Show spinner on this track
|
||||||
|
|
||||||
|
// Set current download info for progress display
|
||||||
|
if (track) {
|
||||||
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await downloadWithAutoFallback(
|
const response = await downloadWithAutoFallback(
|
||||||
isrc,
|
isrc,
|
||||||
settings,
|
settings,
|
||||||
track?.name,
|
track?.name,
|
||||||
track?.artists,
|
track?.artists,
|
||||||
folderName
|
track?.album_name,
|
||||||
|
playlistName,
|
||||||
|
isArtistDiscography
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -280,6 +371,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDownloadingTrack(null); // Clear spinner
|
setDownloadingTrack(null); // Clear spinner
|
||||||
|
setCurrentDownloadInfo(null); // Clear download info
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setBulkDownloadType(null);
|
setBulkDownloadType(null);
|
||||||
shouldStopDownloadRef.current = false; // Reset flag
|
shouldStopDownloadRef.current = false; // Reset flag
|
||||||
@@ -293,7 +385,7 @@ function App() {
|
|||||||
setSelectedTracks([]);
|
setSelectedTracks([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string) => {
|
const handleDownloadAll = async (tracks: TrackMetadata[], playlistName?: string, isArtistDiscography?: boolean) => {
|
||||||
const tracksWithIsrc = tracks.filter(track => track.isrc);
|
const tracksWithIsrc = tracks.filter(track => track.isrc);
|
||||||
|
|
||||||
if (tracksWithIsrc.length === 0) {
|
if (tracksWithIsrc.length === 0) {
|
||||||
@@ -321,13 +413,18 @@ function App() {
|
|||||||
|
|
||||||
setDownloadingTrack(track.isrc); // Show spinner on this track
|
setDownloadingTrack(track.isrc); // Show spinner on this track
|
||||||
|
|
||||||
|
// Set current download info for progress display
|
||||||
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await downloadWithAutoFallback(
|
const response = await downloadWithAutoFallback(
|
||||||
track.isrc,
|
track.isrc,
|
||||||
settings,
|
settings,
|
||||||
track.name,
|
track.name,
|
||||||
track.artists,
|
track.artists,
|
||||||
folderName
|
track.album_name,
|
||||||
|
playlistName,
|
||||||
|
isArtistDiscography
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -344,6 +441,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDownloadingTrack(null); // Clear spinner
|
setDownloadingTrack(null); // Clear spinner
|
||||||
|
setCurrentDownloadInfo(null); // Clear download info
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setBulkDownloadType(null);
|
setBulkDownloadType(null);
|
||||||
shouldStopDownloadRef.current = false; // Reset flag
|
shouldStopDownloadRef.current = false; // Reset flag
|
||||||
@@ -404,7 +502,7 @@ function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -507,7 +605,7 @@ function App() {
|
|||||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
>
|
>
|
||||||
{downloadingTrack === track.isrc ? (
|
{downloadingTrack === track.isrc ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
@@ -607,7 +705,7 @@ function App() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button onClick={() => handleDownloadTrack(track.isrc, track.name, track.artists)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
<Button onClick={() => handleDownloadTrack(track.isrc, track.name, track.artists)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||||
{downloadingTrack === track.isrc ? (
|
{downloadingTrack === track.isrc ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
@@ -653,7 +751,7 @@ function App() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => handleDownloadAll(track_list, album_info.name)} className="gap-2" disabled={isDownloading}>
|
<Button onClick={() => handleDownloadAll(track_list, album_info.name)} className="gap-2" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === 'all' ? (
|
{isDownloading && bulkDownloadType === 'all' ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
@@ -662,7 +760,7 @@ function App() {
|
|||||||
{selectedTracks.length > 0 && (
|
{selectedTracks.length > 0 && (
|
||||||
<Button onClick={handleDownloadSelected} variant="secondary" className="gap-2" disabled={isDownloading}>
|
<Button onClick={handleDownloadSelected} variant="secondary" className="gap-2" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === 'selected' ? (
|
{isDownloading && bulkDownloadType === 'selected' ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
@@ -720,7 +818,7 @@ function App() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => handleDownloadAll(track_list, playlist_info.owner.name)} className="gap-2" disabled={isDownloading}>
|
<Button onClick={() => handleDownloadAll(track_list, playlist_info.owner.name)} className="gap-2" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === 'all' ? (
|
{isDownloading && bulkDownloadType === 'all' ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
@@ -729,7 +827,7 @@ function App() {
|
|||||||
{selectedTracks.length > 0 && (
|
{selectedTracks.length > 0 && (
|
||||||
<Button onClick={handleDownloadSelected} variant="secondary" className="gap-2" disabled={isDownloading}>
|
<Button onClick={handleDownloadSelected} variant="secondary" className="gap-2" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === 'selected' ? (
|
{isDownloading && bulkDownloadType === 'selected' ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
@@ -794,7 +892,11 @@ function App() {
|
|||||||
<h3 className="text-2xl font-bold">Discography</h3>
|
<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">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{album_list.map((album) => (
|
{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">
|
<div className="relative mb-4">
|
||||||
{album.images && (
|
{album.images && (
|
||||||
<img
|
<img
|
||||||
@@ -819,9 +921,9 @@ function App() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
||||||
<div className="flex gap-2">
|
<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' ? (
|
{isDownloading && bulkDownloadType === 'all' ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
@@ -830,7 +932,7 @@ function App() {
|
|||||||
{selectedTracks.length > 0 && (
|
{selectedTracks.length > 0 && (
|
||||||
<Button onClick={handleDownloadSelected} size="sm" variant="secondary" className="gap-2" disabled={isDownloading}>
|
<Button onClick={handleDownloadSelected} size="sm" variant="secondary" className="gap-2" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === 'selected' ? (
|
{isDownloading && bulkDownloadType === 'selected' ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
@@ -889,8 +991,9 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background p-4 md:p-8">
|
<TooltipProvider>
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<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="relative">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
@@ -920,26 +1023,33 @@ function App() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 top-0 flex gap-2">
|
<div className="absolute right-0 top-0 flex gap-2">
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="outline"
|
<TooltipTrigger asChild>
|
||||||
size="icon"
|
<Button
|
||||||
asChild
|
variant="outline"
|
||||||
>
|
size="icon"
|
||||||
<a
|
asChild
|
||||||
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"/>
|
<a
|
||||||
</svg>
|
href="https://github.com/afkarxyz/SpotiFLAC/issues"
|
||||||
</a>
|
target="_blank"
|
||||||
</Button>
|
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 />
|
<Settings />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -974,16 +1084,53 @@ function App() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirmFetch}>
|
<Button onClick={handleConfirmFetch}>
|
||||||
|
<Search className="h-4 w-4 mr-2" />
|
||||||
Fetch
|
Fetch
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
<Card>
|
||||||
<CardContent className="pt-6 space-y-4">
|
<CardContent className="px-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="spotify-url"
|
id="spotify-url"
|
||||||
@@ -995,7 +1142,7 @@ function App() {
|
|||||||
<Button onClick={handleFetchMetadata} disabled={loading}>
|
<Button onClick={handleFetchMetadata} disabled={loading}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner />
|
||||||
Fetching...
|
Fetching...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -1006,16 +1153,14 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Supports track, album, playlist, and artist URLs
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{metadata && renderMetadata()}
|
{metadata && renderMetadata()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Settings as SettingsIcon, FolderOpen } from "lucide-react";
|
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw } from "lucide-react";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, type Settings as SettingsType } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { OpenFolder } from "../../wailsjs/go/main/App";
|
import { OpenFolder } from "../../wailsjs/go/main/App";
|
||||||
|
|
||||||
@@ -33,25 +32,47 @@ export function Settings() {
|
|||||||
|
|
||||||
// Apply saved settings
|
// Apply saved settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (savedSettings.darkMode) {
|
applyThemeMode(savedSettings.themeMode);
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
}
|
|
||||||
applyTheme(savedSettings.theme);
|
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
|
// Apply temp settings for preview when dialog is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (tempSettings.darkMode) {
|
applyThemeMode(tempSettings.themeMode);
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
}
|
|
||||||
applyTheme(tempSettings.theme);
|
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(() => {
|
useEffect(() => {
|
||||||
// Load settings with defaults from backend on mount
|
// Load settings with defaults from backend on mount
|
||||||
@@ -80,13 +101,19 @@ export function Settings() {
|
|||||||
setOpen(false);
|
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 = () => {
|
const handleCancel = () => {
|
||||||
// Revert to saved settings
|
// Revert to saved settings
|
||||||
if (savedSettings.darkMode) {
|
applyThemeMode(savedSettings.themeMode);
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
}
|
|
||||||
applyTheme(savedSettings.theme);
|
applyTheme(savedSettings.theme);
|
||||||
|
|
||||||
setTempSettings(savedSettings);
|
setTempSettings(savedSettings);
|
||||||
@@ -96,11 +123,7 @@ export function Settings() {
|
|||||||
const handleOpenChange = (newOpen: boolean) => {
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
if (!newOpen) {
|
if (!newOpen) {
|
||||||
// Dialog is closing, revert to saved settings
|
// Dialog is closing, revert to saved settings
|
||||||
if (savedSettings.darkMode) {
|
applyThemeMode(savedSettings.themeMode);
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
}
|
|
||||||
applyTheme(savedSettings.theme);
|
applyTheme(savedSettings.theme);
|
||||||
setTempSettings(savedSettings);
|
setTempSettings(savedSettings);
|
||||||
}
|
}
|
||||||
@@ -119,8 +142,8 @@ export function Settings() {
|
|||||||
setTempSettings((prev) => ({ ...prev, theme: value }));
|
setTempSettings((prev) => ({ ...prev, theme: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const handleThemeModeChange = (value: "auto" | "light" | "dark") => {
|
||||||
setTempSettings((prev) => ({ ...prev, darkMode: !prev.darkMode }));
|
setTempSettings((prev) => ({ ...prev, themeMode: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBrowseFolder = async () => {
|
const handleBrowseFolder = async () => {
|
||||||
@@ -145,11 +168,11 @@ export function Settings() {
|
|||||||
<SettingsIcon className="h-5 w-5" />
|
<SettingsIcon className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</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>
|
<DialogHeader>
|
||||||
<DialogTitle>Settings</DialogTitle>
|
<DialogTitle>Settings</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-6 py-4">
|
<div className="grid gap-4 py-2 overflow-y-auto flex-1">
|
||||||
{/* Download Path */}
|
{/* Download Path */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="download-path">Download Path</Label>
|
<Label htmlFor="download-path">Download Path</Label>
|
||||||
@@ -186,50 +209,34 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Settings */}
|
{/* File Settings */}
|
||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-3 pt-3 border-t">
|
||||||
<h3 className="font-medium">File Settings</h3>
|
<h3 className="font-medium text-sm">File Settings</h3>
|
||||||
|
|
||||||
{/* Filename Format */}
|
{/* Filename Format */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>Filename Format</Label>
|
<Label className="text-sm">Filename Format</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={tempSettings.filenameFormat}
|
value={tempSettings.filenameFormat}
|
||||||
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
|
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" />
|
<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>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-1.5">
|
||||||
<RadioGroupItem value="artist-title" id="artist-title" />
|
<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>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-1.5">
|
||||||
<RadioGroupItem value="title" id="title" />
|
<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>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subfolder Options */}
|
{/* Subfolder Options */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<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="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="track-number"
|
id="track-number"
|
||||||
@@ -238,22 +245,43 @@ export function Settings() {
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
|
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dark Mode Toggle */}
|
{/* Theme Mode Selection */}
|
||||||
<div className="flex items-center justify-between pt-4 border-t">
|
<div className="space-y-1.5 pt-3 border-t">
|
||||||
<Label htmlFor="dark-mode">Dark Mode</Label>
|
<Label htmlFor="theme-mode" className="text-sm">Theme</Label>
|
||||||
<Switch
|
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
|
||||||
id="dark-mode"
|
<SelectTrigger id="theme-mode">
|
||||||
checked={tempSettings.darkMode}
|
<SelectValue placeholder="Select theme mode" />
|
||||||
onCheckedChange={toggleDarkMode}
|
</SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Theme Selection */}
|
{/* Theme Color Selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="theme">Theme Color</Label>
|
<Label htmlFor="theme" className="text-sm">Theme Color</Label>
|
||||||
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
||||||
<SelectTrigger id="theme">
|
<SelectTrigger id="theme">
|
||||||
<SelectValue placeholder="Select a theme" />
|
<SelectValue placeholder="Select a theme" />
|
||||||
@@ -268,11 +296,20 @@ export function Settings() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
<Button variant="outline" onClick={handleReset} size="sm" className="gap-1.5">
|
||||||
Cancel
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
Reset to Default
|
||||||
</Button>
|
</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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
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:
|
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:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
"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",
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -24,16 +25,21 @@ const badgeVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export interface BadgeProps
|
function Badge({
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
className,
|
||||||
VariantProps<typeof badgeVariants> {
|
variant,
|
||||||
asChild?: boolean
|
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 (
|
return (
|
||||||
<Comp className={cn(badgeVariants({ variant }), className)} {...props} />
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
import { CheckIcon } from "lucide-react"
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
|||||||
@@ -124,4 +124,4 @@ export {
|
|||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationEllipsis,
|
PaginationEllipsis,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
import { Circle } from "lucide-react"
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const RadioGroup = React.forwardRef<
|
function RadioGroup({
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<RadioGroupPrimitive.Root
|
<RadioGroupPrimitive.Root
|
||||||
className={cn("grid gap-2", className)}
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const RadioGroupItem = React.forwardRef<
|
function RadioGroupItem({
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
return (
|
return (
|
||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
ref={ref}
|
data-slot="radio-group-item"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
<RadioGroupPrimitive.Indicator
|
||||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
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.Indicator>
|
||||||
</RadioGroupPrimitive.Item>
|
</RadioGroupPrimitive.Item>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem }
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -35,7 +35,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -60,7 +60,7 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
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" &&
|
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",
|
"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
|
className
|
||||||
@@ -74,7 +74,7 @@ function SelectContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
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}
|
{children}
|
||||||
@@ -107,7 +107,7 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
||||||
@@ -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();
|
||||||
@@ -4,18 +4,18 @@ export interface Settings {
|
|||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
downloader: "auto" | "deezer" | "tidal";
|
downloader: "auto" | "deezer" | "tidal";
|
||||||
theme: string;
|
theme: string;
|
||||||
darkMode: boolean;
|
themeMode: "auto" | "light" | "dark";
|
||||||
filenameFormat: "title-artist" | "artist-title" | "title";
|
filenameFormat: "title-artist" | "artist-title" | "title";
|
||||||
artistSubfolder: boolean;
|
artistSubfolder: boolean;
|
||||||
albumSubfolder: boolean;
|
albumSubfolder: boolean;
|
||||||
trackNumber: boolean;
|
trackNumber: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
downloader: "auto",
|
downloader: "auto",
|
||||||
theme: "yellow",
|
theme: "yellow",
|
||||||
darkMode: true,
|
themeMode: "auto",
|
||||||
filenameFormat: "title-artist",
|
filenameFormat: "title-artist",
|
||||||
artistSubfolder: false,
|
artistSubfolder: false,
|
||||||
albumSubfolder: false,
|
albumSubfolder: false,
|
||||||
@@ -39,6 +39,11 @@ export function getSettings(): Settings {
|
|||||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(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 };
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -72,3 +77,26 @@ export function updateSettings(partial: Partial<Settings>): Settings {
|
|||||||
saveSettings(updated);
|
saveSettings(updated);
|
||||||
return 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -103,6 +103,8 @@ export interface DownloadRequest {
|
|||||||
output_dir?: string;
|
output_dir?: string;
|
||||||
audio_format?: string;
|
audio_format?: string;
|
||||||
folder_name?: string;
|
folder_name?: string;
|
||||||
|
filename_format?: string;
|
||||||
|
track_number?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadResponse {
|
export interface DownloadResponse {
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ func main() {
|
|||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
Title: "SpotiFLAC",
|
Title: "SpotiFLAC",
|
||||||
Width: 1280,
|
Width: 1024,
|
||||||
Height: 800,
|
Height: 600,
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "5.4"
|
"version": "5.5"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"companyName": "afkarxyz",
|
"companyName": "afkarxyz",
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "5.5",
|
"productVersion": "5.6",
|
||||||
"copyright": "Copyright © 2025",
|
"copyright": "Copyright © 2025",
|
||||||
"comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
|
"comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user