This commit is contained in:
afkarxyz
2026-01-13 22:45:08 +07:00
parent 0f2174bf80
commit 46a7777698
16 changed files with 649 additions and 447 deletions
+4
View File
@@ -1049,3 +1049,7 @@ 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)
}
+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,
+11
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,6 +1070,15 @@ 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{}{
@@ -1080,6 +1090,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
"plays": rank, "plays": rank,
"status": status, "status": status,
"album": albumName, "album": albumName,
"albumArtist": albumArtistsString,
"albumId": albumID, "albumId": albumID,
"duration": durationString, "duration": durationString,
} }
+40 -1
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 {
@@ -233,6 +237,7 @@ type apiPlaylistResponse struct {
Plays string `json:"plays"` Plays string `json:"plays"`
Status string `json:"status"` Status string `json:"status"`
Album string `json:"album"` Album string `json:"album"`
AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId"` AlbumID string `json:"albumId"`
Duration string `json:"duration"` Duration string `json:"duration"`
} `json:"tracks"` } `json:"tracks"`
@@ -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: "",
@@ -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
}
+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.1",
"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.7",
"@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 65caca63c4f7ac1740046770c7a945b0
+171 -171
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.7)(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.1
version: 12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 12.26.1(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.7
version: 25.0.6 version: 25.0.7
'@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.7)(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.7)(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.7':
resolution: {integrity: sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==} resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==}
'@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.1:
resolution: {integrity: sha512-mlWqd0rApIjeyhTCSNCqPYsUAEhkcUukZxH3ke6KbstBRPcxhEpuIjmiUQvB+1E9xkEm5SpNHBgHCapH/QHTWg==} resolution: {integrity: sha512-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw==}
peerDependencies: peerDependencies:
'@emotion/is-prop-valid': '*' '@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
@@ -1757,8 +1757,8 @@ packages:
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.1:
resolution: {integrity: sha512-jBFohEYklpZ+TL64zv03sHdqr1Tsc8/yDy7u68hVzi7hTJYtv53AduchqCiY3aWi4vY1hweS8DWtgCuckusYdQ==} resolution: {integrity: sha512-IVhzx9HOQTiJ9ykthMOlZPnLwrkXziN5Q/yebsqBYlFJb2rHP8yhmKc8O/YUT9byPJlxOeqkzfNYCrVKZx8vqg==}
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.7)(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.7)(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.7':
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.7)(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.7)(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,7 +3399,7 @@ 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.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
motion-dom: 12.24.11 motion-dom: 12.24.11
motion-utils: 12.24.10 motion-utils: 12.24.10
@@ -3560,9 +3560,9 @@ snapshots:
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.1(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.1(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.7)(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.7
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.30.2 lightningcss: 1.30.2
+4 -4
View File
@@ -50,7 +50,7 @@ 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.4"; const CURRENT_VERSION = "7.0.5";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
const lyrics = useLyrics(); const lyrics = useLyrics();
@@ -190,7 +190,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 +200,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 +210,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,
}; };
} }
+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>
+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>
+16 -4
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);
@@ -93,9 +95,19 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
Download Download
</>)} </>)}
</Button> </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> {track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild> <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}> <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"/>)} {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> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -105,7 +117,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
</Tooltip>)} </Tooltip>)}
{track.images && onDownloadCover && (<Tooltip> {track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild> <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}> <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"/>)} {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> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -115,7 +127,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
</Tooltip>)} </Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip> {track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}> <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"/>)} {checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
+51 -8
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));
@@ -239,7 +270,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
<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>
@@ -247,9 +278,19 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
{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 && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => playPreview(track.spotify_id!, track.name)} size="icon" variant="outline" 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> {track.spotify_id && onDownloadLyrics && (<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={() => 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"/>)} {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> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -262,7 +303,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
<Button onClick={() => { <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>
@@ -272,7 +313,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
</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>
@@ -302,14 +343,16 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
}} 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}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => { <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) => {
+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: {
+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,
};
}
+2 -3
View File
@@ -12,9 +12,8 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "7.0.4", "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",