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