Compare commits

..

6 Commits

Author SHA1 Message Date
afkarxyz 4f135f1153 v7.0.5 2026-01-14 08:23:50 +07:00
afkarxyz 4ee252f438 v7.0.5 2026-01-14 07:36:14 +07:00
afkarxyz 2fc08de757 v7.0.5 2026-01-14 06:28:51 +07:00
afkarxyz 6e3ca48d3f v7.0.5 2026-01-13 23:28:06 +07:00
afkarxyz 46a7777698 v7.0.5 2026-01-13 22:45:08 +07:00
afkarxyz 0f2174bf80 v7.0.4 2026-01-11 23:18:18 +07:00
31 changed files with 1964 additions and 1198 deletions
+80 -8
View File
@@ -7,11 +7,20 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"spotiflac/backend" "spotiflac/backend"
"strings" "strings"
"time" "time"
"github.com/wailsapp/wails/v2/pkg/runtime"
) )
var isrcRegex = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$`)
func isValidISRC(isrc string) bool {
return isrcRegex.MatchString(isrc)
}
type App struct { type App struct {
ctx context.Context ctx context.Context
} }
@@ -283,7 +292,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
downloader := backend.NewAmazonDownloader() downloader := backend.NewAmazonDownloader()
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
} else { } else {
if req.SpotifyID == "" { if req.SpotifyID == "" {
return DownloadResponse{ return DownloadResponse{
@@ -291,7 +300,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
Error: "Spotify ID is required for Amazon Music", Error: "Spotify ID is required for Amazon Music",
}, fmt.Errorf("spotify ID is required for Amazon Music") }, fmt.Errorf("spotify ID is required for Amazon Music")
} }
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
} }
case "tidal": case "tidal":
@@ -336,12 +345,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
} }
deezerISRC := req.ISRC deezerISRC := req.ISRC
if deezerISRC != "" {
isrcValid := len(deezerISRC) == 12 && strings.Contains(deezerISRC, "-") if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) {
if !isrcValid { deezerISRC = ""
deezerISRC = ""
}
} }
if deezerISRC == "" && req.SpotifyID != "" { if deezerISRC == "" && req.SpotifyID != "" {
songlinkClient := backend.NewSongLinkClient() songlinkClient := backend.NewSongLinkClient()
@@ -376,6 +384,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
} }
if err != nil { if err != nil {
backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err))
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") { if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
@@ -834,16 +843,19 @@ type DownloadFFmpegResponse struct {
} }
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse { func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
runtime.EventsEmit(a.ctx, "ffmpeg:status", "starting")
err := backend.DownloadFFmpeg(func(progress int) { err := backend.DownloadFFmpeg(func(progress int) {
fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress) runtime.EventsEmit(a.ctx, "ffmpeg:progress", progress)
}) })
if err != nil { if err != nil {
runtime.EventsEmit(a.ctx, "ffmpeg:status", "failed")
return DownloadFFmpegResponse{ return DownloadFFmpegResponse{
Success: false, Success: false,
Error: err.Error(), Error: err.Error(),
} }
} }
runtime.EventsEmit(a.ctx, "ffmpeg:status", "completed")
return DownloadFFmpegResponse{ return DownloadFFmpegResponse{
Success: true, Success: true,
Message: "FFmpeg installed successfully", Message: "FFmpeg installed successfully",
@@ -1055,3 +1067,63 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
func (a *App) SkipDownloadItem(itemID, filePath string) { func (a *App) SkipDownloadItem(itemID, filePath string) {
backend.SkipDownloadItem(itemID, filePath) backend.SkipDownloadItem(itemID, filePath)
} }
func (a *App) GetPreviewURL(trackID string) (string, error) {
return backend.GetPreviewURL(trackID)
}
func (a *App) GetConfigPath() (string, error) {
dir, err := backend.GetFFmpegDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.json"), nil
}
func (a *App) SaveSettings(settings map[string]interface{}) error {
configPath, err := a.GetConfigPath()
if err != nil {
return err
}
dir := filepath.Dir(configPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
func (a *App) LoadSettings() (map[string]interface{}, error) {
configPath, err := a.GetConfigPath()
if err != nil {
return nil, err
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil, nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
return settings, nil
}
func (a *App) CheckFFmpegInstalled() (bool, error) {
return backend.IsFFmpegInstalled()
}
+209 -5
View File
@@ -1,12 +1,15 @@
package backend package backend
import ( import (
"bytes"
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
"net/http" "net/http"
"net/http/cookiejar"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -44,6 +47,22 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"` } `json:"current"`
} }
type LucidaLoadResponse struct {
Success bool `json:"success"`
Server string `json:"server"`
Handoff string `json:"handoff"`
Error string `json:"error"`
}
type LucidaStatusResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Progress struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progress"`
}
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{ return &AmazonDownloader{
client: &http.Client{ client: &http.Client{
@@ -175,8 +194,193 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return amazonURL, nil return amazonURL, nil
} }
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) { func (a *AmazonDownloader) extractData(html string, patterns []string) string {
for _, p := range patterns {
re := regexp.MustCompile(p)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
}
return ""
}
func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality string) (string, error) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
jar, _ := cookiejar.New(nil)
client := &http.Client{
Transport: tr,
Jar: jar,
Timeout: 120 * time.Second,
}
userAgent := a.getRandomUserAgent()
fmt.Printf("Initializing lucida for Amazon Music... (Target: %s)\n", amazonURL)
lucidaBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vP3VybD0lcyZjb3VudHJ5PWF1dG8=")
lucidaURL := fmt.Sprintf(string(lucidaBase), url.QueryEscape(amazonURL))
req, _ := http.NewRequest("GET", lucidaURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
html := string(bodyBytes)
token := a.extractData(html, []string{`token:"([^"]+)"`, `"token"\s*:\s*"([^"]+)"`})
streamURL := a.extractData(html, []string{`"url":"([^"]+)"`, `url:"([^"]+)"`})
expiry := a.extractData(html, []string{`tokenExpiry:(\d+)`, `"tokenExpiry"\s*:\s*(\d+)`})
if token == "" || streamURL == "" {
errorMsg := a.extractData(html, []string{`error:"([^"]+)"`, `"error"\s*:\s*"([^"]+)"`})
if errorMsg != "" {
return "", fmt.Errorf("lucida error: %s", errorMsg)
}
return "", fmt.Errorf("could not extract required data from lucida")
}
decodedToken := token
if secondBase64, err := base64.StdEncoding.DecodeString(token); err == nil {
if firstBase64, err := base64.StdEncoding.DecodeString(string(secondBase64)); err == nil {
decodedToken = string(firstBase64)
}
}
streamURL = strings.ReplaceAll(streamURL, `\/`, `/`)
fmt.Printf("Fetching Amazon stream via Lucida...\n")
loadPayload := map[string]interface{}{
"account": map[string]string{"id": "auto", "type": "country"},
"compat": "false", "downscale": "original", "handoff": true,
"metadata": true, "private": true,
"token": map[string]interface{}{"primary": decodedToken, "expiry": expiry},
"upload": map[string]bool{"enabled": false},
"url": streamURL,
}
payloadBytes, _ := json.Marshal(loadPayload)
loadAPI, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vYXBpL2xvYWQ/dXJsPS9hcGkvZmV0Y2gvc3RyZWFtL3Yy")
req, _ = http.NewRequest("POST", string(loadAPI), bytes.NewBuffer(payloadBytes))
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Content-Type", "application/json")
for _, cookie := range client.Jar.Cookies(req.URL) {
if cookie.Name == "csrf_token" {
req.Header.Set("X-CSRF-Token", cookie.Value)
}
}
resp, err = client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var loadData LucidaLoadResponse
json.NewDecoder(resp.Body).Decode(&loadData)
if !loadData.Success {
return "", fmt.Errorf("lucida load request failed: %s", loadData.Error)
}
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
completionBase, _ := base64.StdEncoding.DecodeString("Lmx1Y2lkYS50by9hcGkvZmV0Y2gvcmVxdWVzdC8=")
completionURL := fmt.Sprintf("%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff)
fmt.Println("Processing on Lucida server...")
var finalStatus LucidaStatusResponse
for {
req, _ = http.NewRequest("GET", completionURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err = client.Do(req)
if err != nil {
return "", err
}
json.NewDecoder(resp.Body).Decode(&finalStatus)
resp.Body.Close()
if finalStatus.Status == "completed" {
fmt.Println("\nTrack processing completed!")
break
} else if finalStatus.Status == "error" {
return "", fmt.Errorf("lucida processing failed: %s", finalStatus.Message)
} else if finalStatus.Progress.Total > 0 {
percent := (finalStatus.Progress.Current * 100) / finalStatus.Progress.Total
fmt.Printf("\rLucida Progress: %d%%", percent)
}
time.Sleep(2 * time.Second)
}
downloadSuffix, _ := base64.StdEncoding.DecodeString("L2Rvd25sb2Fk")
downloadURL := fmt.Sprintf("%s%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff, string(downloadSuffix))
req, _ = http.NewRequest("GET", downloadURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err = client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("lucida download failed with status %d", resp.StatusCode)
}
fileName := "track.flac"
contentDisp := resp.Header.Get("Content-Disposition")
if contentDisp != "" {
re := regexp.MustCompile(`filename[*]?=([^;]+)`)
if matches := re.FindStringSubmatch(contentDisp); len(matches) > 1 {
rawName := strings.Trim(matches[1], `"'`)
if strings.HasPrefix(rawName, "UTF-8''") {
decodedName, _ := url.PathUnescape(rawName[7:])
fileName = decodedName
} else {
fileName = rawName
}
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
}
}
filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath)
if err != nil {
return "", err
}
defer out.Close()
fmt.Printf("Downloading from Lucida: %s\n", fileName)
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
out.Close()
os.Remove(filePath)
return "", fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return filePath, nil
}
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
fmt.Println("Attempting download via Lucida (Priority)...")
filePath, err := a.DownloadFromLucida(amazonURL, outputDir, quality)
if err == nil {
return filePath, nil
}
fmt.Printf("Lucida failed: %v\nTrying Double-Double as fallback...\n", err)
var lastError error var lastError error
lastError = err
for _, region := range a.regions { for _, region := range a.regions {
fmt.Printf("\nTrying region: %s...\n", region) fmt.Printf("\nTrying region: %s...\n", region)
@@ -357,7 +561,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("all regions failed. Last error: %v", lastError) return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
} }
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -377,7 +581,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
fmt.Printf("Using Amazon URL: %s\n", amazonURL) fmt.Printf("Using Amazon URL: %s\n", amazonURL)
filePath, err := a.DownloadFromService(amazonURL, outputDir) filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -492,12 +696,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
return filePath, nil return filePath, nil
} }
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil { if err != nil {
return "", err return "", err
} }
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
} }
+19
View File
@@ -403,6 +403,25 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
outputDir = NormalizePath(outputDir) outputDir = NormalizePath(outputDir)
} }
safeArtist := sanitizeFilename(req.AlbumArtist)
if safeArtist == "" {
safeArtist = sanitizeFilename(req.ArtistName)
}
safeAlbum := sanitizeFilename(req.AlbumName)
if safeArtist != "" && safeAlbum != "" {
artistAlbumPath := filepath.Join(outputDir, safeArtist, safeAlbum)
if info, err := os.Stat(artistAlbumPath); err == nil && info.IsDir() {
outputDir = artistAlbumPath
} else {
artistPath := filepath.Join(outputDir, safeArtist)
if info, err := os.Stat(artistPath); err == nil && info.IsDir() {
outputDir = artistPath
}
}
}
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return &LyricsDownloadResponse{ return &LyricsDownloadResponse{
Success: false, Success: false,
+33 -10
View File
@@ -127,12 +127,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
} }
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode) fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n") fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit\n")
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9") primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode) primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
fmt.Printf("Qobuz API URL: %s\n", primaryURL) fmt.Printf("Trying Primary API: %s\n", primaryURL)
resp, err := q.client.Get(primaryURL) resp, err := q.client.Get(primaryURL)
if err == nil && resp.StatusCode == 200 { if err == nil && resp.StatusCode == 200 {
@@ -143,7 +143,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
var streamResp QobuzStreamResponse var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" { if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
fmt.Printf("Got download URL from primary API\n") fmt.Printf("Got download URL from Primary API\n")
return streamResp.URL, nil return streamResp.URL, nil
} }
} }
@@ -151,20 +151,43 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
resp.Body.Close() resp.Body.Close()
} }
fmt.Println("Primary API failed, trying fallback...") fmt.Println("Primary API failed, trying Fallback API #1...")
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==") fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode) fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
resp, err = q.client.Get(fallbackURL) resp, err = q.client.Get(fallbackURL)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err == nil && len(body) > 0 {
fmt.Printf("Fallback API #1 response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
fmt.Printf("✓ Got download URL from Fallback API #1\n")
return streamResp.URL, nil
}
}
}
if resp != nil {
resp.Body.Close()
}
fmt.Println("Fallback API #1 failed, trying Fallback API #2...")
fallback2Base, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9xb2J1ei5zcXVpZC53dGYvYXBpL2Rvd25sb2FkLW11c2ljP3RyYWNrX2lkPQ==")
fallback2URL := fmt.Sprintf("%s%d&quality=%s", string(fallback2Base), trackID, qualityCode)
resp, err = q.client.Get(fallback2URL)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err) return "", fmt.Errorf("all APIs failed to get download URL: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
fmt.Printf("Fallback API error response: %s\n", string(body)) fmt.Printf("Fallback API #2 error response (status %d): %s\n", resp.StatusCode, string(body))
return "", fmt.Errorf("API returned status %d", resp.StatusCode) return "", fmt.Errorf("all APIs returned non-200 status")
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@@ -176,7 +199,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("API returned empty response") return "", fmt.Errorf("API returned empty response")
} }
fmt.Printf("Fallback API response: %s\n", string(body)) fmt.Printf("Fallback API #2 response: %s\n", string(body))
var streamResp QobuzStreamResponse var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil { if err := json.Unmarshal(body, &streamResp); err != nil {
@@ -189,10 +212,10 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
} }
if streamResp.URL == "" { if streamResp.URL == "" {
return "", fmt.Errorf("no download URL available") return "", fmt.Errorf("no download URL available from any API")
} }
fmt.Printf("Got download URL from fallback API\n") fmt.Printf("Got download URL from Fallback API #2\n")
return streamResp.URL, nil return streamResp.URL, nil
} }
+21 -10
View File
@@ -1049,6 +1049,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
albumData := getMap(trackData, "albumOfTrack") albumData := getMap(trackData, "albumOfTrack")
albumName := "" albumName := ""
albumID := "" albumID := ""
albumArtistsString := ""
var trackCover interface{} var trackCover interface{}
if len(albumData) > 0 { if len(albumData) > 0 {
@@ -1069,19 +1070,29 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
trackCover = getString(coverObj, "large") trackCover = getString(coverObj, "large")
} }
} }
albumArtists := extractArtists(getMap(albumData, "artists"))
if len(albumArtists) > 0 {
albumArtistNames := []string{}
for _, artist := range albumArtists {
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
}
albumArtistsString = strings.Join(albumArtistNames, ", ")
}
} }
trackInfo := map[string]interface{}{ trackInfo := map[string]interface{}{
"id": trackID, "id": trackID,
"cover": trackCover, "cover": trackCover,
"title": getString(trackData, "name"), "title": getString(trackData, "name"),
"artist": artistsString, "artist": artistsString,
"artistIds": artistIDs, "artistIds": artistIDs,
"plays": rank, "plays": rank,
"status": status, "status": status,
"album": albumName, "album": albumName,
"albumId": albumID, "albumArtist": albumArtistsString,
"duration": durationString, "albumId": albumID,
"duration": durationString,
} }
tracks = append(tracks, trackInfo) tracks = append(tracks, trackInfo)
} }
+53 -14
View File
@@ -5,8 +5,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -44,6 +46,7 @@ type TrackMetadata struct {
Copyright string `json:"copyright,omitempty"` Copyright string `json:"copyright,omitempty"`
Publisher string `json:"publisher,omitempty"` Publisher string `json:"publisher,omitempty"`
Plays string `json:"plays,omitempty"` Plays string `json:"plays,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
} }
type ArtistSimple struct { type ArtistSimple struct {
@@ -75,6 +78,7 @@ type AlbumTrackMetadata struct {
ArtistsData []ArtistSimple `json:"artists_data,omitempty"` ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
Plays string `json:"plays,omitempty"` Plays string `json:"plays,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
} }
type TrackResponse struct { type TrackResponse struct {
@@ -225,16 +229,17 @@ type apiPlaylistResponse struct {
Count int `json:"count"` Count int `json:"count"`
Followers int `json:"followers"` Followers int `json:"followers"`
Tracks []struct { Tracks []struct {
ID string `json:"id"` ID string `json:"id"`
Cover string `json:"cover"` Cover string `json:"cover"`
Title string `json:"title"` Title string `json:"title"`
Artist string `json:"artist"` Artist string `json:"artist"`
ArtistIds []string `json:"artistIds"` ArtistIds []string `json:"artistIds"`
Plays string `json:"plays"` Plays string `json:"plays"`
Status string `json:"status"` Status string `json:"status"`
Album string `json:"album"` Album string `json:"album"`
AlbumID string `json:"albumId"` AlbumArtist string `json:"albumArtist"`
Duration string `json:"duration"` AlbumID string `json:"albumId"`
Duration string `json:"duration"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -834,7 +839,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
DiscNumber: raw.Disc, DiscNumber: raw.Disc,
TotalDiscs: raw.Discs, TotalDiscs: raw.Discs,
ExternalURL: externalURL, ExternalURL: externalURL,
ISRC: "", ISRC: raw.ID,
Copyright: raw.Copyright, Copyright: raw.Copyright,
Publisher: raw.Album.Label, Publisher: raw.Album.Label,
Plays: raw.Plays, Plays: raw.Plays,
@@ -892,7 +897,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
DiscNumber: 1, DiscNumber: 1,
TotalDiscs: 0, TotalDiscs: 0,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: "", ISRC: item.ID,
AlbumID: raw.ID, AlbumID: raw.ID,
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID), AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID),
ArtistID: artistID, ArtistID: artistID,
@@ -942,7 +947,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
Artists: item.Artist, Artists: item.Artist,
Name: item.Title, Name: item.Title,
AlbumName: item.Album, AlbumName: item.Album,
AlbumArtist: item.Artist, AlbumArtist: item.AlbumArtist,
DurationMS: durationMS, DurationMS: durationMS,
Images: item.Cover, Images: item.Cover,
ReleaseDate: "", ReleaseDate: "",
@@ -951,7 +956,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
DiscNumber: 1, DiscNumber: 1,
TotalDiscs: 0, TotalDiscs: 0,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: "", ISRC: item.ID,
AlbumID: item.AlbumID, AlbumID: item.AlbumID,
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID), AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID),
ArtistID: artistID, ArtistID: artistID,
@@ -1400,3 +1405,37 @@ func SearchSpotifyByType(ctx context.Context, query string, searchType string, l
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
return client.SearchByType(ctx, query, searchType, limit, offset) return client.SearchByType(ctx, query, searchType, limit, offset)
} }
func GetPreviewURL(trackID string) (string, error) {
if trackID == "" {
return "", errors.New("track ID cannot be empty")
}
embedURL := fmt.Sprintf("https://open.spotify.com/embed/track/%s", trackID)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(embedURL)
if err != nil {
return "", fmt.Errorf("failed to fetch embed page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("embed page returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
html := string(body)
re := regexp.MustCompile(`https://p\.scdn\.co/mp3-preview/[a-zA-Z0-9]+`)
match := re.FindString(html)
if match == "" {
return "", errors.New("preview URL not found")
}
return match, nil
}
+102 -40
View File
@@ -740,28 +740,35 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
} }
type SegmentTemplate struct {
Initialization string `xml:"initialization,attr"`
Media string `xml:"media,attr"`
Timeline struct {
Segments []struct {
Duration int64 `xml:"d,attr"`
Repeat int `xml:"r,attr"`
} `xml:"S"`
} `xml:"SegmentTimeline"`
}
type MPD struct { type MPD struct {
XMLName xml.Name `xml:"MPD"` XMLName xml.Name `xml:"MPD"`
Period struct { Period struct {
AdaptationSet struct { AdaptationSets []struct {
Representation struct { MimeType string `xml:"mimeType,attr"`
SegmentTemplate struct { Codecs string `xml:"codecs,attr"`
Initialization string `xml:"initialization,attr"` Representations []struct {
Media string `xml:"media,attr"` ID string `xml:"id,attr"`
Timeline struct { Codecs string `xml:"codecs,attr"`
Segments []struct { Bandwidth int `xml:"bandwidth,attr"`
Duration int `xml:"d,attr"` SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
Repeat int `xml:"r,attr"`
} `xml:"S"`
} `xml:"SegmentTimeline"`
} `xml:"SegmentTemplate"`
} `xml:"Representation"` } `xml:"Representation"`
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
} `xml:"AdaptationSet"` } `xml:"AdaptationSet"`
} `xml:"Period"` } `xml:"Period"`
} }
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil { if err != nil {
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err) return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
@@ -769,8 +776,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
manifestStr := string(manifestBytes) manifestStr := string(manifestBytes)
if strings.HasPrefix(manifestStr, "{") { if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
var btsManifest TidalBTSManifest var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err) return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
@@ -787,25 +793,78 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
fmt.Println("Manifest: DASH format") fmt.Println("Manifest: DASH format")
var mpd MPD var mpd MPD
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil { var segTemplate *SegmentTemplate
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
var selectedBandwidth int
var selectedCodecs string
for _, as := range mpd.Period.AdaptationSets {
if as.SegmentTemplate != nil {
if segTemplate == nil {
segTemplate = as.SegmentTemplate
selectedCodecs = as.Codecs
}
}
for _, rep := range as.Representations {
if rep.SegmentTemplate != nil {
if rep.Bandwidth > selectedBandwidth {
selectedBandwidth = rep.Bandwidth
segTemplate = rep.SegmentTemplate
if rep.Codecs != "" {
selectedCodecs = rep.Codecs
} else {
selectedCodecs = as.Codecs
}
}
}
}
}
if selectedBandwidth > 0 {
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
}
} }
segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate var mediaTemplate string
initURL = segTemplate.Initialization segmentCount := 0
mediaTemplate := segTemplate.Media
if initURL == "" || mediaTemplate == "" { if segTemplate != nil {
initURL = segTemplate.Initialization
mediaTemplate = segTemplate.Media
initRe := regexp.MustCompile(`initialization="([^"]+)"`) for _, seg := range segTemplate.Timeline.Segments {
mediaRe := regexp.MustCompile(`media="([^"]+)"`) segmentCount += seg.Repeat + 1
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
initURL = match[1]
} }
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 { }
mediaTemplate = match[1]
if segmentCount > 0 && initURL != "" && mediaTemplate != "" {
initURL = strings.ReplaceAll(initURL, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
fmt.Printf("Parsed manifest via XML: %d segments\n", segmentCount)
for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
} }
return "", initURL, mediaURLs, nil
}
fmt.Println("Using regex fallback for DASH manifest...")
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
initURL = match[1]
}
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
mediaTemplate = match[1]
} }
if initURL == "" { if initURL == "" {
@@ -815,23 +874,26 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
initURL = strings.ReplaceAll(initURL, "&amp;", "&") initURL = strings.ReplaceAll(initURL, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&") mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
segmentCount := 0 segmentCount = 0
for _, seg := range segTemplate.Timeline.Segments {
segmentCount += seg.Repeat + 1 segTagRe := regexp.MustCompile(`<S\s+[^>]*>`)
matches := segTagRe.FindAllString(manifestStr, -1)
for _, match := range matches {
repeat := 0
rRe := regexp.MustCompile(`r="(\d+)"`)
if rMatch := rRe.FindStringSubmatch(match); len(rMatch) > 1 {
fmt.Sscanf(rMatch[1], "%d", &repeat)
}
segmentCount += repeat + 1
} }
if segmentCount == 0 { if segmentCount == 0 {
segRe := regexp.MustCompile(`<S d="\d+"(?: r="(\d+)")?`) return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
for _, match := range matches {
repeat := 0
if len(match) > 1 && match[1] != "" {
fmt.Sscanf(match[1], "%d", &repeat)
}
segmentCount += repeat + 1
}
} }
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
for i := 1; i <= segmentCount; i++ { for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL) mediaURLs = append(mediaURLs, mediaURL)
+20 -21
View File
@@ -1,23 +1,22 @@
import js from '@eslint/js' import js from '@eslint/js';
import globals from 'globals' import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist']),
{ {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
reactHooks.configs.flat.recommended, reactHooks.configs.flat.recommended,
reactRefresh.configs.vite, reactRefresh.configs.vite,
], ],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
},
}, },
}, ]);
])
+19 -14
View File
@@ -1,16 +1,21 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" /> <head>
<link rel="icon" type="image/svg+xml" href="/icon.svg" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<title>SpotiFLAC</title> <link
</head> href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap"
<body> rel="stylesheet">
<div id="root"></div> <title>SpotiFLAC</title>
<script type="module" src="/src/main.tsx"></script> </head>
</body>
</html> <body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3 -3
View File
@@ -27,7 +27,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.25.0", "motion": "^12.26.2",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -37,7 +37,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@types/node": "^25.0.6", "@types/node": "^25.0.8",
"@types/react": "^19.2.8", "@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
@@ -48,7 +48,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.52.0", "typescript-eslint": "^8.53.0",
"vite": "^7.3.1" "vite": "^7.3.1"
} }
} }
+1 -1
View File
@@ -1 +1 @@
6f2a6dc27f7d8d215283f6d07b4eaa54 68754ba75ba7fe058dd9ebf6593e2759
+175 -175
View File
@@ -43,7 +43,7 @@ importers:
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.1.18(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2)) version: 4.1.18(vite@7.3.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2))
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -54,8 +54,8 @@ importers:
specifier: ^0.562.0 specifier: ^0.562.0
version: 0.562.0(react@19.2.3) version: 0.562.0(react@19.2.3)
motion: motion:
specifier: ^12.25.0 specifier: ^12.26.2
version: 12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 12.26.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -79,8 +79,8 @@ importers:
specifier: ^9.39.2 specifier: ^9.39.2
version: 9.39.2 version: 9.39.2
'@types/node': '@types/node':
specifier: ^25.0.6 specifier: ^25.0.8
version: 25.0.6 version: 25.0.8
'@types/react': '@types/react':
specifier: ^19.2.8 specifier: ^19.2.8
version: 19.2.8 version: 19.2.8
@@ -89,7 +89,7 @@ importers:
version: 19.2.3(@types/react@19.2.8) version: 19.2.3(@types/react@19.2.8)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^5.1.2 specifier: ^5.1.2
version: 5.1.2(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2)) version: 5.1.2(vite@7.3.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2))
eslint: eslint:
specifier: ^9.39.2 specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
@@ -112,50 +112,50 @@ importers:
specifier: ~5.9.3 specifier: ~5.9.3
version: 5.9.3 version: 5.9.3
typescript-eslint: typescript-eslint:
specifier: ^8.52.0 specifier: ^8.53.0
version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
vite: vite:
specifier: ^7.3.1 specifier: ^7.3.1
version: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2) version: 7.3.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2)
packages: packages:
'@babel/code-frame@7.27.1': '@babel/code-frame@7.28.6':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/compat-data@7.28.5': '@babel/compat-data@7.28.6':
resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/core@7.28.5': '@babel/core@7.28.6':
resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/generator@7.28.5': '@babel/generator@7.28.6':
resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.27.2': '@babel/helper-compilation-targets@7.28.6':
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-globals@7.28.0': '@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-module-imports@7.27.1': '@babel/helper-module-imports@7.28.6':
resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-module-transforms@7.28.3': '@babel/helper-module-transforms@7.28.6':
resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
peerDependencies: peerDependencies:
'@babel/core': ^7.0.0 '@babel/core': ^7.0.0
'@babel/helper-plugin-utils@7.27.1': '@babel/helper-plugin-utils@7.28.6':
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1': '@babel/helper-string-parser@7.27.1':
@@ -170,12 +170,12 @@ packages:
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helpers@7.28.4': '@babel/helpers@7.28.6':
resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/parser@7.28.5': '@babel/parser@7.28.6':
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -191,16 +191,16 @@ packages:
peerDependencies: peerDependencies:
'@babel/core': ^7.0.0-0 '@babel/core': ^7.0.0-0
'@babel/template@7.27.2': '@babel/template@7.28.6':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/traverse@7.28.5': '@babel/traverse@7.28.6':
resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/types@7.28.5': '@babel/types@7.28.6':
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@emnapi/runtime@1.8.1': '@emnapi/runtime@1.8.1':
@@ -1259,8 +1259,8 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@25.0.6': '@types/node@25.0.8':
resolution: {integrity: sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==} resolution: {integrity: sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==}
'@types/react-dom@19.2.3': '@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
@@ -1270,63 +1270,63 @@ packages:
'@types/react@19.2.8': '@types/react@19.2.8':
resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==}
'@typescript-eslint/eslint-plugin@8.52.0': '@typescript-eslint/eslint-plugin@8.53.0':
resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==} resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.52.0 '@typescript-eslint/parser': ^8.53.0
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.52.0': '@typescript-eslint/parser@8.53.0':
resolution: {integrity: sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==} resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.52.0': '@typescript-eslint/project-service@8.53.0':
resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==} resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.52.0': '@typescript-eslint/scope-manager@8.53.0':
resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==} resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.52.0': '@typescript-eslint/tsconfig-utils@8.53.0':
resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==} resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.52.0': '@typescript-eslint/type-utils@8.53.0':
resolution: {integrity: sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==} resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.52.0': '@typescript-eslint/types@8.53.0':
resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==} resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.52.0': '@typescript-eslint/typescript-estree@8.53.0':
resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==} resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.52.0': '@typescript-eslint/utils@8.53.0':
resolution: {integrity: sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==} resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.52.0': '@typescript-eslint/visitor-keys@8.53.0':
resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==} resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@5.1.2': '@vitejs/plugin-react@5.1.2':
@@ -1540,8 +1540,8 @@ packages:
flatted@3.3.3: flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
framer-motion@12.25.0: framer-motion@12.26.2:
resolution: {integrity: sha512-mlWqd0rApIjeyhTCSNCqPYsUAEhkcUukZxH3ke6KbstBRPcxhEpuIjmiUQvB+1E9xkEm5SpNHBgHCapH/QHTWg==} resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
peerDependencies: peerDependencies:
'@emotion/is-prop-valid': '*' '@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
@@ -1751,14 +1751,14 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
motion-dom@12.24.11: motion-dom@12.26.2:
resolution: {integrity: sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==} resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==}
motion-utils@12.24.10: motion-utils@12.24.10:
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==} resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
motion@12.25.0: motion@12.26.2:
resolution: {integrity: sha512-jBFohEYklpZ+TL64zv03sHdqr1Tsc8/yDy7u68hVzi7hTJYtv53AduchqCiY3aWi4vY1hweS8DWtgCuckusYdQ==} resolution: {integrity: sha512-2Q6g0zK1gUJKhGT742DAe42LgietcdiJ3L3OcYAHCQaC1UkLnn6aC8S/obe4CxYTLAgid2asS1QdQ/blYfo5dw==}
peerDependencies: peerDependencies:
'@emotion/is-prop-valid': '*' '@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
@@ -1958,8 +1958,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
typescript-eslint@8.52.0: typescript-eslint@8.53.0:
resolution: {integrity: sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==} resolution: {integrity: sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
@@ -2069,25 +2069,25 @@ packages:
snapshots: snapshots:
'@babel/code-frame@7.27.1': '@babel/code-frame@7.28.6':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0 js-tokens: 4.0.0
picocolors: 1.1.1 picocolors: 1.1.1
'@babel/compat-data@7.28.5': {} '@babel/compat-data@7.28.6': {}
'@babel/core@7.28.5': '@babel/core@7.28.6':
dependencies: dependencies:
'@babel/code-frame': 7.27.1 '@babel/code-frame': 7.28.6
'@babel/generator': 7.28.5 '@babel/generator': 7.28.6
'@babel/helper-compilation-targets': 7.27.2 '@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6)
'@babel/helpers': 7.28.4 '@babel/helpers': 7.28.6
'@babel/parser': 7.28.5 '@babel/parser': 7.28.6
'@babel/template': 7.27.2 '@babel/template': 7.28.6
'@babel/traverse': 7.28.5 '@babel/traverse': 7.28.6
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.4.3 debug: 4.4.3
@@ -2097,17 +2097,17 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/generator@7.28.5': '@babel/generator@7.28.6':
dependencies: dependencies:
'@babel/parser': 7.28.5 '@babel/parser': 7.28.6
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0 jsesc: 3.1.0
'@babel/helper-compilation-targets@7.27.2': '@babel/helper-compilation-targets@7.28.6':
dependencies: dependencies:
'@babel/compat-data': 7.28.5 '@babel/compat-data': 7.28.6
'@babel/helper-validator-option': 7.27.1 '@babel/helper-validator-option': 7.27.1
browserslist: 4.28.1 browserslist: 4.28.1
lru-cache: 5.1.1 lru-cache: 5.1.1
@@ -2115,23 +2115,23 @@ snapshots:
'@babel/helper-globals@7.28.0': {} '@babel/helper-globals@7.28.0': {}
'@babel/helper-module-imports@7.27.1': '@babel/helper-module-imports@7.28.6':
dependencies: dependencies:
'@babel/traverse': 7.28.5 '@babel/traverse': 7.28.6
'@babel/types': 7.28.5 '@babel/types': 7.28.6
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.6
'@babel/helper-module-imports': 7.27.1 '@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.28.5 '@babel/traverse': 7.28.6
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-plugin-utils@7.27.1': {} '@babel/helper-plugin-utils@7.28.6': {}
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.27.1': {}
@@ -2139,44 +2139,44 @@ snapshots:
'@babel/helper-validator-option@7.27.1': {} '@babel/helper-validator-option@7.27.1': {}
'@babel/helpers@7.28.4': '@babel/helpers@7.28.6':
dependencies: dependencies:
'@babel/template': 7.27.2 '@babel/template': 7.28.6
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@babel/parser@7.28.5': '@babel/parser@7.28.6':
dependencies: dependencies:
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.6
'@babel/helper-plugin-utils': 7.27.1 '@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.6
'@babel/helper-plugin-utils': 7.27.1 '@babel/helper-plugin-utils': 7.28.6
'@babel/template@7.27.2': '@babel/template@7.28.6':
dependencies: dependencies:
'@babel/code-frame': 7.27.1 '@babel/code-frame': 7.28.6
'@babel/parser': 7.28.5 '@babel/parser': 7.28.6
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@babel/traverse@7.28.5': '@babel/traverse@7.28.6':
dependencies: dependencies:
'@babel/code-frame': 7.27.1 '@babel/code-frame': 7.28.6
'@babel/generator': 7.28.5 '@babel/generator': 7.28.6
'@babel/helper-globals': 7.28.0 '@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.5 '@babel/parser': 7.28.6
'@babel/template': 7.27.2 '@babel/template': 7.28.6
'@babel/types': 7.28.5 '@babel/types': 7.28.6
debug: 4.4.3 debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/types@7.28.5': '@babel/types@7.28.6':
dependencies: dependencies:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
@@ -3016,39 +3016,39 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2))': '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2))':
dependencies: dependencies:
'@tailwindcss/node': 4.1.18 '@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18 '@tailwindcss/oxide': 4.1.18
tailwindcss: 4.1.18 tailwindcss: 4.1.18
vite: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2) vite: 7.3.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2)
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.28.5 '@babel/parser': 7.28.6
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@types/babel__generator': 7.27.0 '@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4 '@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0 '@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0': '@types/babel__generator@7.27.0':
dependencies: dependencies:
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@types/babel__template@7.4.4': '@types/babel__template@7.4.4':
dependencies: dependencies:
'@babel/parser': 7.28.5 '@babel/parser': 7.28.6
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
dependencies: dependencies:
'@babel/types': 7.28.5 '@babel/types': 7.28.6
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@25.0.6': '@types/node@25.0.8':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
@@ -3060,14 +3060,14 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.52.0 '@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.52.0 '@typescript-eslint/visitor-keys': 8.53.0
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
ignore: 7.0.5 ignore: 7.0.5
natural-compare: 1.4.0 natural-compare: 1.4.0
@@ -3076,41 +3076,41 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.52.0 '@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/types': 8.52.0 '@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.52.0 '@typescript-eslint/visitor-keys': 8.53.0
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.52.0(typescript@5.9.3)': '@typescript-eslint/project-service@8.53.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3)
'@typescript-eslint/types': 8.52.0 '@typescript-eslint/types': 8.53.0
debug: 4.4.3 debug: 4.4.3
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/scope-manager@8.52.0': '@typescript-eslint/scope-manager@8.53.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.52.0 '@typescript-eslint/types': 8.53.0
'@typescript-eslint/visitor-keys': 8.52.0 '@typescript-eslint/visitor-keys': 8.53.0
'@typescript-eslint/tsconfig-utils@8.52.0(typescript@5.9.3)': '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)':
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/type-utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.52.0 '@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@5.9.3) ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -3118,14 +3118,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/types@8.52.0': {} '@typescript-eslint/types@8.53.0': {}
'@typescript-eslint/typescript-estree@8.52.0(typescript@5.9.3)': '@typescript-eslint/typescript-estree@8.53.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.52.0(typescript@5.9.3) '@typescript-eslint/project-service': 8.53.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3)
'@typescript-eslint/types': 8.52.0 '@typescript-eslint/types': 8.53.0
'@typescript-eslint/visitor-keys': 8.52.0 '@typescript-eslint/visitor-keys': 8.53.0
debug: 4.4.3 debug: 4.4.3
minimatch: 9.0.5 minimatch: 9.0.5
semver: 7.7.3 semver: 7.7.3
@@ -3135,31 +3135,31 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.52.0 '@typescript-eslint/scope-manager': 8.53.0
'@typescript-eslint/types': 8.52.0 '@typescript-eslint/types': 8.53.0
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/visitor-keys@8.52.0': '@typescript-eslint/visitor-keys@8.53.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.52.0 '@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2))': '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2))':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.6
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6)
'@rolldown/pluginutils': 1.0.0-beta.53 '@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.18.0 react-refresh: 0.18.0
vite: 7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2) vite: 7.3.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3292,8 +3292,8 @@ snapshots:
eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)):
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.6
'@babel/parser': 7.28.5 '@babel/parser': 7.28.6
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
hermes-parser: 0.25.1 hermes-parser: 0.25.1
zod: 4.3.5 zod: 4.3.5
@@ -3399,9 +3399,9 @@ snapshots:
flatted@3.3.3: {} flatted@3.3.3: {}
framer-motion@12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): framer-motion@12.26.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
motion-dom: 12.24.11 motion-dom: 12.26.2
motion-utils: 12.24.10 motion-utils: 12.24.10
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
@@ -3554,15 +3554,15 @@ snapshots:
dependencies: dependencies:
brace-expansion: 2.0.2 brace-expansion: 2.0.2
motion-dom@12.24.11: motion-dom@12.26.2:
dependencies: dependencies:
motion-utils: 12.24.10 motion-utils: 12.24.10
motion-utils@12.24.10: {} motion-utils@12.24.10: {}
motion@12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): motion@12.26.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
framer-motion: 12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) framer-motion: 12.26.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
react: 19.2.3 react: 19.2.3
@@ -3768,12 +3768,12 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): typescript-eslint@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1) eslint: 9.39.2(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3808,7 +3808,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.8 '@types/react': 19.2.8
vite@7.3.1(@types/node@25.0.6)(jiti@2.6.1)(lightningcss@1.30.2): vite@7.3.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2):
dependencies: dependencies:
esbuild: 0.27.2 esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -3817,7 +3817,7 @@ snapshots:
rollup: 4.55.1 rollup: 4.55.1
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 25.0.6 '@types/node': 25.0.8
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.30.2 lightningcss: 1.30.2
+13 -22
View File
@@ -2,32 +2,23 @@ import sharp from 'sharp';
import { readFileSync, mkdirSync } from 'fs'; import { readFileSync, mkdirSync } from 'fs';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..', '..'); const rootDir = join(__dirname, '..', '..');
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg'); const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
const outputPath = join(rootDir, 'build', 'appicon.png'); const outputPath = join(rootDir, 'build', 'appicon.png');
async function generateIcon() { async function generateIcon() {
try { try {
// Ensure build directory exists mkdirSync(join(rootDir, 'build'), { recursive: true });
mkdirSync(join(rootDir, 'build'), { recursive: true }); const svgBuffer = readFileSync(svgPath);
await sharp(svgBuffer)
// Read SVG .resize(1024, 1024)
const svgBuffer = readFileSync(svgPath); .png()
.toFile(outputPath);
// Convert SVG to PNG (1024x1024 for Wails) console.log('✓ Icon generated:', outputPath);
await sharp(svgBuffer) }
.resize(1024, 1024) catch (error) {
.png() console.error('✗ Failed to generate icon:', error.message);
.toFile(outputPath); process.exit(1);
}
console.log('✓ Icon generated:', outputPath);
} catch (error) {
console.error('✗ Failed to generate icon:', error.message);
process.exit(1);
}
} }
generateIcon(); generateIcon();
+164 -155
View File
@@ -1,11 +1,11 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useLayoutEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Search, X, ArrowUp } from "lucide-react"; import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes"; import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App"; import { OpenFolder } from "../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -50,22 +50,31 @@ function App() {
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "7.0.3"; const CURRENT_VERSION = "7.0.5";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
const lyrics = useLyrics(); const lyrics = useLyrics();
const cover = useCover(); const cover = useCover();
const availability = useAvailability(); const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog(); const downloadQueue = useDownloadQueueDialog();
useLayoutEffect(() => {
const savedSettings = getSettings();
if (savedSettings) {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
}
}, []);
useEffect(() => { useEffect(() => {
const initSettings = async () => { const initSettings = async () => {
const settings = getSettings(); const settings = await loadSettings();
applyThemeMode(settings.themeMode); applyThemeMode(settings.themeMode);
applyTheme(settings.theme); applyTheme(settings.theme);
applyFont(settings.fontFamily); applyFont(settings.fontFamily);
if (!settings.downloadPath) { if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults(); const settingsWithDefaults = await getSettingsWithDefaults();
saveSettings(settingsWithDefaults); await saveSettings(settingsWithDefaults);
} }
}; };
initSettings(); initSettings();
@@ -190,7 +199,7 @@ function App() {
url: spotifyUrl, url: spotifyUrl,
type: "album", type: "album",
name: album_info.name, name: album_info.name,
artist: `${album_info.total_tracks} tracks`, artist: `${album_info.total_tracks.toLocaleString()} tracks`,
image: album_info.images, image: album_info.images,
}; };
} }
@@ -200,7 +209,7 @@ function App() {
url: spotifyUrl, url: spotifyUrl,
type: "playlist", type: "playlist",
name: playlist_info.owner.name, name: playlist_info.owner.name,
artist: `${playlist_info.tracks.total} tracks`, artist: `${playlist_info.tracks.total.toLocaleString()} tracks`,
image: playlist_info.cover || playlist_info.owner.images || "", image: playlist_info.cover || playlist_info.owner.images || "",
}; };
} }
@@ -210,7 +219,7 @@ function App() {
url: spotifyUrl, url: spotifyUrl,
type: "artist", type: "artist",
name: artist_info.name, name: artist_info.name,
artist: `${artist_info.total_albums} albums`, artist: `${artist_info.total_albums.toLocaleString()} albums`,
image: artist_info.images, image: artist_info.images,
}; };
} }
@@ -253,49 +262,49 @@ function App() {
return null; return null;
if ("track" in metadata.metadata) { if ("track" in metadata.metadata) {
const { track } = metadata.metadata; const { track } = metadata.metadata;
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.getAvailability(track.spotify_id || "")} downloadingCover={cover.downloadingCover} downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")} failedCover={cover.failedCovers.has(track.spotify_id || "")} skippedCover={cover.skippedCovers.has(track.spotify_id || "")} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onOpenFolder={handleOpenFolder}/>); return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} />);
} }
if ("album_info" in metadata.metadata) { if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata; const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, undefined, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => { return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
} }
}} onTrackClick={async (track) => { }} onTrackClick={async (track) => {
if (track.external_urls) { if (track.external_urls) {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}}/>); }} />);
} }
if ("playlist_info" in metadata.metadata) { if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata; const { playlist_info, track_list } = metadata.metadata;
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
} }
}} onTrackClick={async (track) => { }} onTrackClick={async (track) => {
if (track.external_urls) { if (track.external_urls) {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}}/>); }} />);
} }
if ("artist_info" in metadata.metadata) { if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata; const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
} }
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => { }} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
if (track.external_urls) { if (track.external_urls) {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}}/>); }} />);
} }
return null; return null;
}; };
@@ -328,7 +337,7 @@ function App() {
const renderPage = () => { const renderPage = () => {
switch (currentPage) { switch (currentPage) {
case "settings": case "settings":
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>; return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn} />;
case "debug": case "debug":
return <DebugLoggerPage />; return <DebugLoggerPage />;
case "audio-analysis": case "audio-analysis":
@@ -339,133 +348,133 @@ function App() {
return <FileManagerPage />; return <FileManagerPage />;
default: default:
return (<> return (<>
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/> <Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate} />
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
<X className="h-4 w-4"/>
</Button>
</div>
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
<DialogDescription>
Set timeout for fetching metadata. Longer timeout is recommended for artists
with large discography.
</DialogDescription>
{metadata.pendingArtistName && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
</div>)}
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="timeout">Timeout (seconds)</Label>
<Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}/>
<p className="text-xs text-muted-foreground">
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
minutes).
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
Cancel
</Button>
<Button onClick={metadata.handleConfirmFetch}>
<Search className="h-4 w-4"/>
Fetch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}> <DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden"> <div className="absolute right-4 top-4">
<div className="absolute right-4 top-4"> <Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}> <X className="h-4 w-4" />
<X className="h-4 w-4"/> </Button>
</Button> </div>
</div> <DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle> <DialogDescription>
<DialogDescription> Set timeout for fetching metadata. Longer timeout is recommended for artists
Do you want to fetch metadata for this album? with large discography.
</DialogDescription> </DialogDescription>
{metadata.selectedAlbum && (<div className="py-2"> {metadata.pendingArtistName && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p> <p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
</div>)} </div>)}
<DialogFooter> <div className="space-y-4 py-4">
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}> <div className="space-y-2">
Cancel <Label htmlFor="timeout">Timeout (seconds)</Label>
</Button> <Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))} />
<Button onClick={async () => { <p className="text-xs text-muted-foreground">
const albumUrl = await metadata.handleConfirmAlbumFetch(); Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
if (albumUrl) { minutes).
setSpotifyUrl(albumUrl); </p>
} </div>
}}> </div>
<Search className="h-4 w-4"/> <DialogFooter>
Fetch Album <Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
</Button> Cancel
</DialogFooter> </Button>
</DialogContent> <Button onClick={metadata.handleConfirmFetch}>
</Dialog> <Search className="h-4 w-4" />
Fetch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
<DialogDescription>
Do you want to fetch metadata for this album?
</DialogDescription>
{metadata.selectedAlbum && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
</div>)}
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
Cancel
</Button>
<Button onClick={async () => {
const albumUrl = await metadata.handleConfirmAlbumFetch();
if (albumUrl) {
setSpotifyUrl(albumUrl);
}
}}>
<Search className="h-4 w-4" />
Fetch Album
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
setSpotifyUrl(url); setSpotifyUrl(url);
const updatedUrl = await metadata.handleFetchMetadata(url); const updatedUrl = await metadata.handleFetchMetadata(url);
if (updatedUrl) { if (updatedUrl) {
setSpotifyUrl(updatedUrl); setSpotifyUrl(updatedUrl);
} }
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/> }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} />
{!isSearchMode && metadata.metadata && renderMetadata()} {!isSearchMode && metadata.metadata && renderMetadata()}
</>); </>);
} }
}; };
return (<TooltipProvider> return (<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col"> <div className="min-h-screen bg-background flex flex-col">
<TitleBar /> <TitleBar />
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/> <Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
<div className="flex-1 ml-14 mt-10 p-4 md:p-8"> <div className="flex-1 ml-14 mt-10 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{renderPage()} {renderPage()}
</div> </div>
</div>
<DownloadProgressToast onClick={downloadQueue.openQueue} />
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue} />
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
<ArrowUp className="h-5 w-5" />
</Button>)}
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
<DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription>
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancelNavigation}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDiscardChanges}>
Discard Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
<ArrowUp className="h-5 w-5"/>
</Button>)}
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
<DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription>
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancelNavigation}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDiscardChanges}>
Discard Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>); </TooltipProvider>);
} }
export default App; export default App;
+2 -2
View File
@@ -90,7 +90,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
<span>{albumInfo.release_date}</span> <span>{albumInfo.release_date}</span>
<span></span> <span></span>
<span> <span>
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"} {albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"}
</span> </span>
</div> </div>
</div> </div>
@@ -101,7 +101,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
</Button> </Button>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}> {selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)} {isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)} </Button>)}
{onDownloadAllLyrics && (<Tooltip> {onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
+1 -1
View File
@@ -415,7 +415,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Button> </Button>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}> {selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)} {isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)} </Button>)}
{onDownloadAllLyrics && (<Tooltip> {onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
+125 -113
View File
@@ -3,7 +3,8 @@ import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App"; import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { backend } from "../../wailsjs/go/models"; import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps { interface DownloadQueueProps {
isOpen: boolean; isOpen: boolean;
@@ -47,6 +48,17 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
console.error("Failed to clear history:", error); console.error("Failed to clear history:", error);
} }
}; };
const handleReset = async () => {
try {
await ClearAllDownloads();
const info = await GetDownloadQueue();
setQueueInfo(info);
toast.success("Download queue reset");
}
catch (error) {
console.error("Failed to reset queue:", error);
}
};
const getStatusIcon = (status: string) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case "downloading": case "downloading":
@@ -72,8 +84,8 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
queued: "outline", queued: "outline",
}; };
return (<Badge variant={variants[status] || "outline"} className="text-xs"> return (<Badge variant={variants[status] || "outline"} className="text-xs">
{status} {status}
</Badge>); </Badge>);
}; };
const formatDuration = (startTimestamp: number) => { const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0) if (startTimestamp === 0)
@@ -94,138 +106,138 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
} }
}; };
return (<Dialog open={isOpen} onOpenChange={onClose}> return (<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden"> <DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0"> <DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle> <DialogTitle className="text-lg font-semibold hover:text-primary transition-colors cursor-pointer" onClick={handleReset}>Download Queue</DialogTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}> {(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
<Trash2 className="h-3 w-3"/> <Trash2 className="h-3 w-3"/>
Clear History Clear History
</Button>)} </Button>)}
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}> <Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
<X className="h-4 w-4"/> <X className="h-4 w-4"/>
</Button> </Button>
</div>
</div> </div>
</div>
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground"/> <Clock className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Queued:</span> <span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span> <span className="font-semibold">{queueInfo.queued_count}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500"/>
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div> </div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500"/>
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div>
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t"> <div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/> <HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Downloaded:</span> <span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono"> <span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"} {queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground"/> <Zap className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Speed:</span> <span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono"> <span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading {queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s` ? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"} : "—"}
</span> </span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div> </div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p>No downloads in queue</p>
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
<div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1 min-w-0"> <div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="flex items-start justify-between gap-2 mb-1"> <div className="space-y-2 py-4">
<div className="flex-1 min-w-0"> {queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<p className="font-medium truncate">{item.track_name}</p> <Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p className="text-sm text-muted-foreground truncate"> <p>No downloads in queue</p>
{item.artist_name} </div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
{item.album_name && `${item.album_name}`} <div className="flex items-start gap-3">
</p> <div className="mt-1">{getStatusIcon(item.status)}</div>
</div>
{getStatusBadge(item.status)}
</div>
<div className="flex-1 min-w-0">
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono"> <div className="flex items-start justify-between gap-2 mb-1">
<span> <div className="flex-1 min-w-0">
{item.progress > 0 <p className="font-medium truncate">{item.track_name}</p>
<p className="text-sm text-muted-foreground truncate">
{item.artist_name}
{item.album_name && `${item.album_name}`}
</p>
</div>
{getStatusBadge(item.status)}
</div>
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span>
{item.progress > 0
? `${item.progress.toFixed(2)} MB` ? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0 : queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..." ? "Downloading..."
: "Starting..."} : "Starting..."}
</span> </span>
<span> <span>
{item.speed > 0 {item.speed > 0
? `${item.speed.toFixed(2)} MB/s` ? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0 : queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s` ? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"} : "—"}
</span> </span>
</div>)} </div>)}
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div>)}
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground"> <span className="font-mono">{item.progress.toFixed(2)} MB</span>
File already exists </div>)}
</div>)}
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
{item.error_message}
</div>)}
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono"> File already exists
{item.file_path} </div>)}
</div>)}
</div>
</div> {item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
</div>)))} {item.error_message}
</div> </div>)}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>)}
</div>
</div>
</div>)))}
</div> </div>
</DialogContent> </div>
</Dialog>); </DialogContent>
</Dialog>);
} }
+2 -2
View File
@@ -97,7 +97,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
</div> </div>
<span></span> <span></span>
<span> <span>
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"} {playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
</span> </span>
<span></span> <span></span>
<span>{playlistInfo.followers.total.toLocaleString()} followers</span> <span>{playlistInfo.followers.total.toLocaleString()} followers</span>
@@ -110,7 +110,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
</Button> </Button>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}> {selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)} {isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)} </Button>)}
{onDownloadAllLyrics && (<Tooltip> {onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
+377 -298
View File
@@ -8,354 +8,433 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react"; import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Progress } from "@/components/ui/progress";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App"; import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground"> const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path> <path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path> <path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>); </svg>);
const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground"> const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path> <path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path> <path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>); </svg>);
const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground"> const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>); </svg>);
interface SettingsPageProps { interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void; onResetRequest?: (resetFn: () => void) => void;
} }
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) { export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings()); const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings); const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); const [showFFmpegWarning, setShowFFmpegWarning] = useState(false);
const resetToSaved = useCallback(() => { const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
const freshSavedSettings = getSettings(); const [installProgress, setInstallProgress] = useState(0);
flushSync(() => { const downloadProgress = useDownloadProgress();
setTempSettings(freshSavedSettings); const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
setIsDark(document.documentElement.classList.contains('dark')); const resetToSaved = useCallback(() => {
}); const freshSavedSettings = getSettings();
}, []); flushSync(() => {
useEffect(() => { setTempSettings(freshSavedSettings);
if (onResetRequest) { setIsDark(document.documentElement.classList.contains('dark'));
onResetRequest(resetToSaved); });
} }, []);
}, [onResetRequest, resetToSaved]); useEffect(() => {
useEffect(() => { if (onResetRequest) {
onUnsavedChangesChange?.(hasUnsavedChanges); onResetRequest(resetToSaved);
}, [hasUnsavedChanges, onUnsavedChangesChange]); }
useEffect(() => { }, [onResetRequest, resetToSaved]);
applyThemeMode(savedSettings.themeMode); useEffect(() => {
onUnsavedChangesChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChangesChange]);
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme); applyTheme(savedSettings.theme);
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]);
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
saveSettings(settingsWithDefaults);
}
};
loadDefaults();
}, []);
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
onUnsavedChangesChange?.(false);
}; };
const handleReset = async () => { mediaQuery.addEventListener("change", handleChange);
const defaultSettings = await resetToDefaultSettings(); return () => mediaQuery.removeEventListener("change", handleChange);
setTempSettings(defaultSettings); }, [savedSettings.themeMode, savedSettings.theme]);
setSavedSettings(defaultSettings); useEffect(() => {
applyThemeMode(defaultSettings.themeMode); applyThemeMode(tempSettings.themeMode);
applyTheme(defaultSettings.theme); applyTheme(tempSettings.theme);
applyFont(defaultSettings.fontFamily); applyFont(tempSettings.fontFamily);
setShowResetConfirm(false); setTimeout(() => {
toast.success("Settings reset to default"); setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
await saveSettings(settingsWithDefaults);
}
}; };
const handleBrowseFolder = async () => { loadDefaults();
try { }, []);
const selectedPath = await SelectFolder(tempSettings.downloadPath || ""); const handleSave = async () => {
if (selectedPath && selectedPath.trim() !== "") { await saveSettings(tempSettings);
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath })); setSavedSettings(tempSettings);
} toast.success("Settings saved");
onUnsavedChangesChange?.(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false);
toast.success("Settings reset to default");
};
const handleBrowseFolder = async () => {
try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
}
}
catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
if (value === "HI_RES_LOSSLESS") {
try {
const { CheckFFmpegInstalled } = await import("../../wailsjs/go/main/App");
const isInstalled = await CheckFFmpegInstalled();
if (!isInstalled) {
setShowFFmpegWarning(true);
return;
} }
catch (error) { }
console.error("Error selecting folder:", error); catch (error) {
toast.error(`Error selecting folder: ${error}`); console.error("Error checking FFmpeg:", error);
} }
}; }
return (<div className="space-y-6"> setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
<h1 className="text-2xl font-bold">Settings</h1> };
const handleInstallFFmpeg = async () => {
setIsInstallingFFmpeg(true);
setInstallProgress(0);
try {
const { DownloadFFmpeg } = await import("../../wailsjs/go/main/App");
const { EventsOn, EventsOff } = await import("../../wailsjs/runtime/runtime");
EventsOn("ffmpeg:progress", (progress: number) => {
setInstallProgress(progress);
});
const response = await DownloadFFmpeg();
EventsOff("ffmpeg:progress");
if (response.success) {
toast.success("FFmpeg installed successfully!");
setShowFFmpegWarning(false);
setTempSettings((prev) => ({ ...prev, tidalQuality: "HI_RES_LOSSLESS" }));
}
else {
toast.error(`Failed to install FFmpeg: ${response.error}`);
}
}
catch (error) {
console.error("Error installing FFmpeg:", error);
toast.error(`Error during FFmpeg installation: ${error}`);
}
finally {
setIsInstallingFFmpeg(false);
setInstallProgress(0);
}
};
return (<div className="space-y-6">
<h1 className="text-2xl font-bold">Settings</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label> <Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/> <InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music" />
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5"> <Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/> <FolderOpen className="h-4 w-4" />
Browse Browse
</Button> </Button>
</div>
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label> <Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}> <Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode"> <SelectTrigger id="theme-mode">
<SelectValue placeholder="Select 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>
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}} />
{theme.label}
</span>
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font">
<SelectValue placeholder="Select a font" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))} />
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="downloader" className="text-sm">Source</Label>
<div className="flex gap-2">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem> <SelectItem value="tidal">
<SelectItem value="dark">Dark</SelectItem> <span className="flex items-center"><TidalIcon />Tidal</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center"><QobuzIcon />Qobuz</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div>
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<div className="space-y-2"> <SelectTrigger className="h-9 w-fit">
<Label htmlFor="theme">Accent</Label> <SelectValue />
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}> <SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
<span className="flex items-center gap-2"> <SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
<span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}}/>
{theme.label}
</span>
</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>)}
</div>
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
<div className="space-y-2"> <SelectTrigger className="h-9 w-fit">
<Label htmlFor="font">Font</Label> <SelectValue />
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font">
<SelectValue placeholder="Select a font"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}> <SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span> <SelectItem value="7">FLAC 24-bit (Studio Quality)</SelectItem>
</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>)}
</div>
{tempSettings.downloader === "amazon" && (
<div className="flex items-center gap-3"> <div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label> 16-bit/44.1kHz or 24-bit/48kHz+
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/> </div>
)}
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="flex items-center gap-6">
<div className="flex items-center gap-3">
<div className="space-y-2"> <Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Label htmlFor="downloader" className="text-sm">Source</Label> <Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))} />
<div className="flex gap-2">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center"><TidalIcon />Tidal</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center"><QobuzIcon />Qobuz</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
</SelectItem>
</SelectContent>
</Select>
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
<SelectItem value="7">FLAC 24-bit</SelectItem>
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={(value: "HI_RES") => setTempSettings((prev) => ({ ...prev, amazonQuality: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HI_RES">Hi-Res (24-bit/96kHz+)</SelectItem>
</SelectContent>
</Select>)}
</div>
</div> </div>
<div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<div className="flex items-center gap-6"> <Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))} />
<div className="flex items-center gap-3">
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
</div>
</div> </div>
</div>
<div className="border-t"/> <div className="border-t" />
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label> <Label className="text-sm">Folder Structure</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => { <Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value]; const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({ setTempSettings(prev => ({
...prev, ...prev,
folderPreset: value, folderPreset: value,
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
})); }));
}}> }}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))} {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)} {tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1" />)}
</div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
</p>)}
</div> </div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
</p>)}
</div>
<div className="border-t"/> <div className="border-t" />
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label> <Label className="text-sm">Filename Format</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => { <Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value]; const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({ setTempSettings(prev => ({
...prev, ...prev,
filenamePreset: value, filenamePreset: value,
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
})); }));
}}> }}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))} {Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} {tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1" />)}
</div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>)}
</div> </div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>)}
</div> </div>
</div> </div>
</div>
<div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4"/>
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4"/>
Save Changes
</Button>
</div>
<div className="flex gap-2 justify-between pt-4 border-t">
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}> <Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<DialogContent className="max-w-md [&>button]:hidden"> <RotateCcw className="h-4 w-4" />
<DialogHeader> Reset to Default
<DialogTitle>Reset to Default?</DialogTitle> </Button>
<DialogDescription> <Button onClick={handleSave} className="gap-1.5">
This will reset all settings to their default values. Your custom configurations will be lost. <Save className="h-4 w-4" />
</DialogDescription> Save Changes
</DialogHeader> </Button>
<DialogFooter> </div>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={handleReset}>Reset</Button>
</DialogFooter> <Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
</DialogContent> <DialogContent className="max-w-md [&>button]:hidden">
</Dialog> <DialogHeader>
</div>); <DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>
This will reset all settings to their default values. Your custom configurations will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={handleReset}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showFFmpegWarning} onOpenChange={(open) => !isInstallingFFmpeg && setShowFFmpegWarning(open)}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>FFmpeg Required</DialogTitle>
<DialogDescription className="space-y-4 pt-2">
<div className="space-y-2">
<p>Tidal 24-bit (Hi-Res Lossless) downloads audio in segmented files that need to be merged into a single FLAC file.</p>
<p>FFmpeg is required to merge these segments. {isInstallingFFmpeg ? "Installing FFmpeg..." : "Would you like to install FFmpeg now?"}</p>
</div>
{isInstallingFFmpeg && (<div className="space-y-2 py-2">
<div className="flex justify-between text-xs font-medium">
<div className="flex flex-col gap-1">
<span>Downloading & Extracting...</span>
{downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-muted-foreground font-normal">
{downloadProgress.mb_downloaded.toFixed(2)} MB
{downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(2)} MB/s`}
</span>)}
</div>
<span>{installProgress}%</span>
</div>
<Progress value={installProgress} className="h-2" />
</div>)}
</DialogDescription>
</DialogHeader>
{!isInstallingFFmpeg && (<DialogFooter>
<Button variant="outline" onClick={() => setShowFFmpegWarning(false)}>Cancel</Button>
<Button onClick={handleInstallFFmpeg}>Install FFmpeg</Button>
</DialogFooter>)}
</DialogContent>
</Dialog>
</div>);
} }
+103 -91
View File
@@ -1,10 +1,11 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react"; import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview";
interface TrackInfoProps { interface TrackInfoProps {
track: TrackMetadata & { track: TrackMetadata & {
album_name: string; album_name: string;
@@ -32,6 +33,7 @@ interface TrackInfoProps {
onOpenFolder: () => void; onOpenFolder: () => void;
} }
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) { export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
const formatDuration = (ms: number) => { const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000); const seconds = Math.floor((ms % 60000) / 1000);
@@ -44,96 +46,106 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
return num.toLocaleString(); return num.toLocaleString();
}; };
return (<Card> return (<Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
<div className="shrink-0"> <div className="shrink-0">
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden"> {track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/> <img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded"> <div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
{formatDuration(track.duration_ms)} {formatDuration(track.duration_ms)}
</div>
</div>)}
</div>
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm"> </div>)}
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{track.album_name}</p>
</div>
{track.plays && (<div>
<p className="text-xs text-muted-foreground">Total Plays</p>
<p className="font-medium">{formatPlays(track.plays)}</p>
</div>)}
</div>
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Release Date</p>
<p className="font-medium">{track.release_date}</p>
</div>
{track.copyright && (<div>
<p className="text-xs text-muted-foreground">Copyright</p>
<p className="font-medium truncate" title={track.copyright}>
{track.copyright}
</p>
</div>)}
</div>
</div>
{track.isrc && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
<Download className="h-4 w-4"/>
Download
</>)}
</Button>
{track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingCover}>
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}>
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>)}
</div>)}
</div>
</div> </div>
</CardContent> <div className="flex-1 space-y-4 min-w-0">
</Card>); <div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{track.album_name}</p>
</div>
{track.plays && (<div>
<p className="text-xs text-muted-foreground">Total Plays</p>
<p className="font-medium">{formatPlays(track.plays)}</p>
</div>)}
</div>
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Release Date</p>
<p className="font-medium">{track.release_date}</p>
</div>
{track.copyright && (<div>
<p className="text-xs text-muted-foreground">Copyright</p>
<p className="font-medium truncate" title={track.copyright}>
{track.copyright}
</p>
</div>)}
</div>
</div>
{track.isrc && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
<Download className="h-4 w-4"/>
Download
</>)}
</Button>
{track.spotify_id && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => playPreview(track.spotify_id!, track.name)} variant="outline" size="icon" disabled={loadingPreview === track.spotify_id}>
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingCover}>
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" size="icon" disabled={checkingAvailability}>
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>)}
</div>)}
</div>
</div>
</CardContent>
</Card>);
} }
+188 -145
View File
@@ -1,11 +1,12 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react"; import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview";
interface TrackListProps { interface TrackListProps {
tracks: TrackMetadata[]; tracks: TrackMetadata[];
searchQuery: string; searchQuery: string;
@@ -52,6 +53,7 @@ interface TrackListProps {
onTrackClick?: (track: TrackMetadata) => void; onTrackClick?: (track: TrackMetadata) => void;
} }
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) { export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
let filteredTracks = tracks.filter((track) => { let filteredTracks = tracks.filter((track) => {
if (!searchQuery) if (!searchQuery)
return true; return true;
@@ -118,6 +120,35 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
const startIndex = (currentPage - 1) * itemsPerPage; const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage; const endIndex = startIndex + itemsPerPage;
const paginatedTracks = filteredTracks.slice(startIndex, endIndex); const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
if (total <= 10) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | 'ellipsis')[] = [];
pages.push(1);
if (current <= 7) {
for (let i = 2; i <= 10; i++) {
pages.push(i);
}
pages.push('ellipsis');
pages.push(total);
}
else if (current >= total - 7) {
pages.push('ellipsis');
for (let i = total - 9; i <= total; i++) {
pages.push(i);
}
}
else {
pages.push('ellipsis');
pages.push(current - 1);
pages.push(current);
pages.push(current + 1);
pages.push('ellipsis');
pages.push(total);
}
return pages;
};
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc); const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
const allSelected = tracksWithIsrc.length > 0 && const allSelected = tracksWithIsrc.length > 0 &&
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc)); tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
@@ -135,192 +166,204 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
return num.toLocaleString(); return num.toLocaleString();
}; };
return (<div className="space-y-4"> return (<div className="space-y-4">
<div className="rounded-md border"> <div className="rounded-md border">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12"> {showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/> <Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
</th>)} </th>)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
# #
</th> </th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title Title
</th> </th>
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell"> {!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
Album Album
</th>)} </th>)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
Duration Duration
</th> </th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
Plays Plays
</th> </th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32"> <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50"> {paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (<td className="p-4 align-middle"> {showCheckboxes && (<td className="p-4 align-middle">
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)} {track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
</td>)} </td>)}
<td className="p-4 align-middle text-sm text-muted-foreground"> <td className="p-4 align-middle text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-0.5"> <div className="flex flex-col items-center gap-0.5">
<span>{startIndex + index + 1}</span> <span>{startIndex + index + 1}</span>
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP" {track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
? "text-green-500" ? "text-green-500"
: track.status === "DOWN" : track.status === "DOWN"
? "text-red-500" ? "text-red-500"
: track.status === "NEW" : track.status === "NEW"
? "text-blue-500" ? "text-blue-500"
: ""}`}> : ""}`}>
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"} {track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
</span>)} </span>)}
</div>
</td>
<td className="p-4 align-middle">
<div className="flex items-center gap-3">
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
{track.name}
</span>) : (<span className="font-medium">{track.name}</span>)}
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
</div> </div>
</td> <span className="text-sm text-muted-foreground">
<td className="p-4 align-middle"> {track.artists_data && track.artists_data.length > 0 ? ((() => {
<div className="flex items-center gap-3">
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
{track.name}
</span>) : (<span className="font-medium">{track.name}</span>)}
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
</div>
<span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? ((() => {
const artistNames = track.artists.split(", ").map(name => name.trim()); const artistNames = track.artists.split(", ").map(name => name.trim());
return artistNames.map((name, i) => { return artistNames.map((name, i) => {
const artistData = track.artists_data![i]; const artistData = track.artists_data![i];
const hasArtistData = artistData && artistData.id && artistData.external_urls; const hasArtistData = artistData && artistData.id && artistData.external_urls;
return (<span key={artistData?.id || i}> return (<span key={artistData?.id || i}>
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({ {onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artistData.id, id: artistData.id,
name: name, name: name,
external_urls: artistData.external_urls, external_urls: artistData.external_urls,
})}> })}>
{name} {name}
</span>) : (name)} </span>) : (name)}
{i < artistNames.length - 1 && ", "} {i < artistNames.length - 1 && ", "}
</span>); </span>);
}); });
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({ })()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: track.artist_id!, id: track.artist_id!,
name: track.artists, name: track.artists,
external_urls: track.artist_url!, external_urls: track.artist_url!,
})}> })}>
{track.artists} {track.artists}
</span>) : (track.artists)} </span>) : (track.artists)}
</span> </span>
</div> </div>
</div> </div>
</td> </td>
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell"> {!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({ {onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
id: track.album_id!, id: track.album_id!,
name: track.album_name, name: track.album_name,
external_urls: track.album_url!, external_urls: track.album_url!,
})}> })}>
{track.album_name} {track.album_name}
</span>) : (track.album_name)} </span>) : (track.album_name)}
</td>)} </td>)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell"> <td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
{formatDuration(track.duration_ms)} {formatDuration(track.duration_ms)}
</td> </td>
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell"> <td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
{track.plays ? formatPlays(track.plays) : ""} {track.plays ? formatPlays(track.plays) : ""}
</td> </td>
<td className="p-4 align-middle text-center"> <td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{track.isrc && (<Tooltip> {track.isrc && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="sm" disabled={isDownloading || downloadingTrack === track.isrc}> <Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)} {downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)} {downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
{track.spotify_id && onDownloadLyrics && (<Tooltip> {track.spotify_id && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="sm" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}> <Button onClick={() => playPreview(track.spotify_id!, track.name)} size="icon" variant="outline" disabled={loadingPreview === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)} {loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download Lyric</p> <p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
{track.images && onDownloadCover && (<Tooltip> {track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={() => { <Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="icon" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => {
const trackId = track.spotify_id || `${track.name}-${track.artists}`; const trackId = track.spotify_id || `${track.name}-${track.artists}`;
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number); onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
}} size="sm" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}> }} size="icon" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)} {downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download Cover</p> <p>Download Cover</p>
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip> {track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="sm" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}> <Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)} {checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2"> {availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/> <TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/> <QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/> <AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)} </div>) : (<p>Check Availability</p>)}
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
</div> </div>
</td> </td>
</tr>))} </tr>))}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
</div>
{totalPages > 1 && (<Pagination> {totalPages > 1 && (<Pagination>
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationPrevious href="#" onClick={(e) => { <PaginationPrevious href="#" onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (currentPage > 1) if (currentPage > 1)
onPageChange(currentPage - 1); onPageChange(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/> }} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem> </PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}> {getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationLink href="#" onClick={(e) => { <PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault(); e.preventDefault();
onPageChange(page); onPageChange(page);
}} isActive={currentPage === page} className="cursor-pointer"> }} isActive={currentPage === page} className="cursor-pointer">
{page} {page}
</PaginationLink> </PaginationLink>
</PaginationItem>))} </PaginationItem>)))}
<PaginationItem> <PaginationItem>
<PaginationNext href="#" onClick={(e) => { <PaginationNext href="#" onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (currentPage < totalPages) if (currentPage < totalPages)
onPageChange(currentPage + 1); onPageChange(currentPage + 1);
}} className={currentPage === totalPages }} className={currentPage === totalPages
? "pointer-events-none opacity-50" ? "pointer-events-none opacity-50"
: "cursor-pointer"}/> : "cursor-pointer"}/>
</PaginationItem> </PaginationItem>
</PaginationContent> </PaginationContent>
</Pagination>)} </Pagination>)}
</div>); </div>);
} }
+3 -3
View File
@@ -16,9 +16,9 @@ const buttonVariants = cva("inline-flex items-center justify-center gap-2 whites
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "h-9 w-9 p-0",
"icon-sm": "size-8", "icon-sm": "h-8 w-8 p-0",
"icon-lg": "size-10", "icon-lg": "h-10 w-10 p-0",
}, },
}, },
defaultVariants: { defaultVariants: {
+6 -2
View File
@@ -35,7 +35,9 @@ export function useCover() {
track: position, track: position,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName && !isAlbum) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -129,7 +131,9 @@ export function useCover() {
track: trackPosition, track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName && !isAlbum) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
+10 -5
View File
@@ -44,7 +44,7 @@ export function useDownload() {
const shouldStopDownloadRef = useRef(false); const shouldStopDownloadRef = useRef(false);
const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader; const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem; const os = settings.operatingSystem;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false; let useAlbumTrackNumber = false;
@@ -82,7 +82,9 @@ export function useDownload() {
year: yearValue, year: yearValue,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && !useAlbumSubfolder) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -346,7 +348,8 @@ export function useDownload() {
year: yearValue, year: yearValue,
playlist: folderName?.replace(/\//g, placeholder), playlist: folderName?.replace(/\//g, placeholder),
}; };
if (folderName && !isAlbum) { const useAlbumTag = settings.folderTemplate?.includes("{album}");
if (folderName && (!isAlbum || !useAlbumTag)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -575,7 +578,8 @@ export function useDownload() {
setDownloadProgress(0); setDownloadProgress(0);
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
if (folderName && !isAlbum) { const useAlbumTag = settings.folderTemplate?.includes("{album}");
if (folderName && (!isAlbum || !useAlbumTag)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
const selectedTrackObjects = selectedTracks const selectedTrackObjects = selectedTracks
@@ -723,7 +727,8 @@ export function useDownload() {
setDownloadProgress(0); setDownloadProgress(0);
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
if (folderName && !isAlbum) { const useAlbumTag = settings.folderTemplate?.includes("{album}");
if (folderName && (!isAlbum || !useAlbumTag)) {
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
} }
logger.info(`checking existing files in parallel...`); logger.info(`checking existing files in parallel...`);
+6 -2
View File
@@ -32,7 +32,9 @@ export function useLyrics() {
track: position, track: position,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName && !isAlbum) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
@@ -125,7 +127,9 @@ export function useLyrics() {
track: trackPosition, track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder), playlist: playlistName?.replace(/\//g, placeholder),
}; };
if (playlistName && !isAlbum) { const folderTemplate = settings.folderTemplate || "";
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
} }
if (settings.folderTemplate) { if (settings.folderTemplate) {
+75
View File
@@ -0,0 +1,75 @@
import { useState } from "react";
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
import { toast } from "sonner";
export function usePreview() {
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
const playPreview = async (trackId: string, trackName: string) => {
try {
if (playingTrack === trackId && currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setPlayingTrack(null);
setCurrentAudio(null);
return;
}
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null);
}
setLoadingPreview(trackId);
const previewURL = await GetPreviewURL(trackId);
if (!previewURL) {
toast.error("Preview not available", {
description: `No preview found for "${trackName}"`,
});
setLoadingPreview(null);
return;
}
const audio = new Audio(previewURL);
audio.addEventListener("loadeddata", () => {
setLoadingPreview(null);
setPlayingTrack(trackId);
});
audio.addEventListener("ended", () => {
setPlayingTrack(null);
setCurrentAudio(null);
});
audio.addEventListener("error", () => {
toast.error("Failed to play preview", {
description: `Could not play preview for "${trackName}"`,
});
setLoadingPreview(null);
setPlayingTrack(null);
setCurrentAudio(null);
});
setCurrentAudio(audio);
await audio.play();
}
catch (error: any) {
console.error("Preview error:", error);
toast.error("Preview not available", {
description: error?.message || `Could not load preview for "${trackName}"`,
});
setLoadingPreview(null);
setPlayingTrack(null);
}
};
const stopPreview = () => {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null);
}
};
return {
playPreview,
stopPreview,
loadingPreview,
playingTrack,
};
}
+24 -11
View File
@@ -26,6 +26,7 @@
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--font-sans: "Bricolage Grotesque", "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
:root { :root {
@@ -75,11 +76,15 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: var(--font-sans);
} }
code, pre, .font-mono {
code,
pre,
.font-mono {
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
} }
} }
@@ -134,43 +139,51 @@
/* Specific color for each toast type - match icon color */ /* Specific color for each toast type - match icon color */
[data-sonner-toast][data-type="success"] [data-description], [data-sonner-toast][data-type="success"] [data-description],
[data-sonner-toast][data-type="success"] [data-description] * { [data-sonner-toast][data-type="success"] [data-description] * {
color: rgb(22 163 74) !important; /* green-600 - same as icon */ color: rgb(22 163 74) !important;
/* green-600 - same as icon */
} }
[data-sonner-toast][data-type="error"] [data-description], [data-sonner-toast][data-type="error"] [data-description],
[data-sonner-toast][data-type="error"] [data-description] * { [data-sonner-toast][data-type="error"] [data-description] * {
color: rgb(220 38 38) !important; /* red-600 - same as icon */ color: rgb(220 38 38) !important;
/* red-600 - same as icon */
} }
[data-sonner-toast][data-type="warning"] [data-description], [data-sonner-toast][data-type="warning"] [data-description],
[data-sonner-toast][data-type="warning"] [data-description] * { [data-sonner-toast][data-type="warning"] [data-description] * {
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */ color: rgb(202 138 4) !important;
/* yellow-600 - same as icon */
} }
[data-sonner-toast][data-type="info"] [data-description], [data-sonner-toast][data-type="info"] [data-description],
[data-sonner-toast][data-type="info"] [data-description] * { [data-sonner-toast][data-type="info"] [data-description] * {
color: rgb(37 99 235) !important; /* blue-600 - same as icon */ color: rgb(37 99 235) !important;
/* blue-600 - same as icon */
} }
/* Dark mode - use same icon colors */ /* Dark mode - use same icon colors */
.dark [data-sonner-toast][data-type="success"] [data-description], .dark [data-sonner-toast][data-type="success"] [data-description],
.dark [data-sonner-toast][data-type="success"] [data-description] * { .dark [data-sonner-toast][data-type="success"] [data-description] * {
color: rgb(22 163 74) !important; /* green-600 */ color: rgb(22 163 74) !important;
/* green-600 */
} }
.dark [data-sonner-toast][data-type="error"] [data-description], .dark [data-sonner-toast][data-type="error"] [data-description],
.dark [data-sonner-toast][data-type="error"] [data-description] * { .dark [data-sonner-toast][data-type="error"] [data-description] * {
color: rgb(220 38 38) !important; /* red-600 */ color: rgb(220 38 38) !important;
/* red-600 */
} }
.dark [data-sonner-toast][data-type="warning"] [data-description], .dark [data-sonner-toast][data-type="warning"] [data-description],
.dark [data-sonner-toast][data-type="warning"] [data-description] * { .dark [data-sonner-toast][data-type="warning"] [data-description] * {
color: rgb(202 138 4) !important; /* yellow-600 */ color: rgb(202 138 4) !important;
/* yellow-600 */
} }
.dark [data-sonner-toast][data-type="info"] [data-description], .dark [data-sonner-toast][data-type="info"] [data-description],
.dark [data-sonner-toast][data-type="info"] [data-description] * { .dark [data-sonner-toast][data-type="info"] [data-description] * {
color: rgb(37 99 235) !important; /* blue-600 */ color: rgb(37 99 235) !important;
/* blue-600 */
} }
/* Dark mode toast styling */ /* Dark mode toast styling */
@@ -252,4 +265,4 @@
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2); filter: brightness(1.2);
} }
+117 -30
View File
@@ -1,5 +1,5 @@
import { GetDefaults } from "../../wailsjs/go/main/App"; import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans"; export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom"; export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom"; export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export interface Settings { export interface Settings {
@@ -21,8 +21,8 @@ export interface Settings {
embedMaxQualityCover: boolean; embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS"; operatingSystem: "Windows" | "linux/MacOS";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27"; qobuzQuality: "6" | "7";
amazonQuality: "HI_RES"; amazonQuality: "original";
} }
export const FOLDER_PRESETS: Record<FolderPreset, { export const FOLDER_PRESETS: Record<FolderPreset, {
label: string; label: string;
@@ -95,30 +95,31 @@ export const DEFAULT_SETTINGS: Settings = {
operatingSystem: detectOS(), operatingSystem: detectOS(),
tidalQuality: "LOSSLESS", tidalQuality: "LOSSLESS",
qobuzQuality: "6", qobuzQuality: "6",
amazonQuality: "HI_RES" amazonQuality: "original"
}; };
export const FONT_OPTIONS: { export const FONT_OPTIONS: {
value: FontFamily; value: FontFamily;
label: string; label: string;
fontFamily: string; fontFamily: string;
}[] = [ }[] = [
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' }, { value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' }, { value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' }, { value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' }, { value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' }, { value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' }, { value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' }, { value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' }, { value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' }, { value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' }, { value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' }, { value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' }, { value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' }, { value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' }, { value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' }, { value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' }, { value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
]; { value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
];
export function applyFont(fontFamily: FontFamily): void { export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily); const font = FONT_OPTIONS.find(f => f.value === fontFamily);
if (font) { if (font) {
@@ -137,7 +138,8 @@ async function fetchDefaultPath(): Promise<string> {
} }
} }
const SETTINGS_KEY = "spotiflac-settings"; const SETTINGS_KEY = "spotiflac-settings";
export function getSettings(): Settings { let cachedSettings: Settings | null = null;
function getSettingsFromLocalStorage(): Settings {
try { try {
const stored = localStorage.getItem(SETTINGS_KEY); const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) { if (stored) {
@@ -188,17 +190,99 @@ export function getSettings(): Settings {
if (!('qobuzQuality' in parsed)) { if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6"; parsed.qobuzQuality = "6";
} }
if (parsed.qobuzQuality === "27") {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "HI_RES"; parsed.amazonQuality = "original";
} }
return { ...DEFAULT_SETTINGS, ...parsed }; return { ...DEFAULT_SETTINGS, ...parsed };
} }
} }
catch (error) { catch (error) {
console.error("Failed to load settings:", error); console.error("Failed to load settings from local storage:", error);
} }
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
export function getSettings(): Settings {
if (cachedSettings)
return cachedSettings;
return getSettingsFromLocalStorage();
}
export async function loadSettings(): Promise<Settings> {
try {
const backendSettings = await LoadSettings();
if (backendSettings) {
const parsed = backendSettings as any;
if ('darkMode' in parsed && !('themeMode' in parsed)) {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
const hasArtist = parsed.artistSubfolder;
const hasAlbum = parsed.albumSubfolder;
if (hasArtist && hasAlbum) {
parsed.folderPreset = "artist-album";
parsed.folderTemplate = "{artist}/{album}";
}
else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
}
else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
}
else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
}
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
const format = parsed.filenameFormat;
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
parsed.operatingSystem = detectOS();
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
if (parsed.qobuzQuality === "27") {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original";
}
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!;
}
}
catch (error) {
console.error("Failed to load settings from backend:", error);
}
const local = getSettingsFromLocalStorage();
try {
await SaveToBackend(local as any);
cachedSettings = local;
}
catch (error) {
console.error("Failed to migrate settings to backend:", error);
}
return local;
}
export interface TemplateData { export interface TemplateData {
artist?: string; artist?: string;
album?: string; album?: string;
@@ -224,30 +308,33 @@ export function parseTemplate(template: string, data: TemplateData): string {
return result; return result;
} }
export async function getSettingsWithDefaults(): Promise<Settings> { export async function getSettingsWithDefaults(): Promise<Settings> {
const settings = getSettings(); const settings = await loadSettings();
if (!settings.downloadPath) { if (!settings.downloadPath) {
settings.downloadPath = await fetchDefaultPath(); settings.downloadPath = await fetchDefaultPath();
await saveSettings(settings);
} }
return settings; return settings;
} }
export function saveSettings(settings: Settings): void { export async function saveSettings(settings: Settings): Promise<void> {
try { try {
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
await SaveToBackend(settings as any);
} }
catch (error) { catch (error) {
console.error("Failed to save settings:", error); console.error("Failed to save settings:", error);
} }
} }
export function updateSettings(partial: Partial<Settings>): Settings { export async function updateSettings(partial: Partial<Settings>): Promise<Settings> {
const current = getSettings(); const current = getSettings();
const updated = { ...current, ...partial }; const updated = { ...current, ...partial };
saveSettings(updated); await saveSettings(updated);
return updated; return updated;
} }
export async function resetToDefaultSettings(): Promise<Settings> { export async function resetToDefaultSettings(): Promise<Settings> {
const defaultPath = await fetchDefaultPath(); const defaultPath = await fetchDefaultPath();
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath }; const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
saveSettings(defaultSettings); await saveSettings(defaultSettings);
return defaultSettings; return defaultSettings;
} }
export function applyThemeMode(mode: "auto" | "light" | "dark"): void { export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
+1
View File
@@ -45,6 +45,7 @@ export interface AlbumResponse {
track_list: TrackMetadata[]; track_list: TrackMetadata[];
} }
export interface PlaylistInfo { export interface PlaylistInfo {
name: string;
tracks: { tracks: {
total: number; total: number;
}; };
+10 -12
View File
@@ -1,14 +1,12 @@
import path from "path" import path from "path";
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react";
import { defineConfig } from "vite" import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
},
}, },
}, });
})
+2 -3
View File
@@ -12,9 +12,8 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "7.0.3", "productVersion": "7.0.5",
"copyright": "© 2026 afkarxyz", "copyright": "© 2026 afkarxyz"
"comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required."
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",