Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f135f1153 | |||
| 4ee252f438 | |||
| 2fc08de757 | |||
| 6e3ca48d3f | |||
| 46a7777698 | |||
| 0f2174bf80 | |||
| 36fb34dc63 |
@@ -7,11 +7,20 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"spotiflac/backend"
|
||||
"strings"
|
||||
"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 {
|
||||
ctx context.Context
|
||||
}
|
||||
@@ -283,7 +292,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
downloader := backend.NewAmazonDownloader()
|
||||
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 {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -291,7 +300,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "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":
|
||||
@@ -336,6 +345,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
deezerISRC := req.ISRC
|
||||
|
||||
if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) {
|
||||
deezerISRC = ""
|
||||
}
|
||||
|
||||
if deezerISRC == "" && req.SpotifyID != "" {
|
||||
|
||||
songlinkClient := backend.NewSongLinkClient()
|
||||
@@ -370,6 +384,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err))
|
||||
|
||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||
|
||||
@@ -828,16 +843,19 @@ type DownloadFFmpegResponse struct {
|
||||
}
|
||||
|
||||
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
||||
runtime.EventsEmit(a.ctx, "ffmpeg:status", "starting")
|
||||
err := backend.DownloadFFmpeg(func(progress int) {
|
||||
fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress)
|
||||
runtime.EventsEmit(a.ctx, "ffmpeg:progress", progress)
|
||||
})
|
||||
if err != nil {
|
||||
runtime.EventsEmit(a.ctx, "ffmpeg:status", "failed")
|
||||
return DownloadFFmpegResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
runtime.EventsEmit(a.ctx, "ffmpeg:status", "completed")
|
||||
return DownloadFFmpegResponse{
|
||||
Success: true,
|
||||
Message: "FFmpeg installed successfully",
|
||||
@@ -1049,3 +1067,63 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
|
||||
func (a *App) SkipDownloadItem(itemID, filePath string) {
|
||||
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
@@ -1,12 +1,15 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -44,6 +47,22 @@ type DoubleDoubleStatusResponse struct {
|
||||
} `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 {
|
||||
return &AmazonDownloader{
|
||||
client: &http.Client{
|
||||
@@ -175,8 +194,193 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
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
|
||||
lastError = err
|
||||
|
||||
for _, region := range a.regions {
|
||||
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)
|
||||
}
|
||||
|
||||
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 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)
|
||||
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -492,12 +696,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
+15
-5
@@ -12,6 +12,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
@@ -118,21 +119,30 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
||||
return filename + ".cover.jpg"
|
||||
}
|
||||
|
||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize640) {
|
||||
return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1)
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||
|
||||
mediumURL := convertSmallToMedium(imageURL)
|
||||
if strings.Contains(mediumURL, spotifySize640) {
|
||||
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
return mediumURL
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
downloadURL := coverURL
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if embedMaxQualityCover {
|
||||
downloadURL = c.getMaxResolutionURL(coverURL)
|
||||
downloadURL = c.getMaxResolutionURL(downloadURL)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
|
||||
@@ -403,6 +403,25 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
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 {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
|
||||
+33
-10
@@ -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("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")
|
||||
|
||||
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)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
@@ -143,7 +143,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -151,20 +151,43 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
fmt.Println("Primary API failed, trying fallback...")
|
||||
fmt.Println("Primary API failed, trying Fallback API #1...")
|
||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
||||
|
||||
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 {
|
||||
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()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Fallback API error response: %s\n", string(body))
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
fmt.Printf("Fallback API #2 error response (status %d): %s\n", resp.StatusCode, string(body))
|
||||
return "", fmt.Errorf("all APIs returned non-200 status")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
fmt.Printf("Fallback API response: %s\n", string(body))
|
||||
fmt.Printf("Fallback API #2 response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||
@@ -189,10 +212,10 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||
|
||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||
|
||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||
|
||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||
'っ': "",
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||
|
||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||
|
||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||
|
||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||
'ッ': "",
|
||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||
|
||||
'ー': "",
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
var combinationHiragana = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||
}
|
||||
|
||||
var combinationKatakana = map[string]string{
|
||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||
|
||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
}
|
||||
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHiragana(r rune) bool {
|
||||
return r >= 0x3040 && r <= 0x309F
|
||||
}
|
||||
|
||||
func isKatakana(r rune) bool {
|
||||
return r >= 0x30A0 && r <= 0x30FF
|
||||
}
|
||||
|
||||
func isKanji(r rune) bool {
|
||||
return (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||
(r >= 0x3400 && r <= 0x4DBF)
|
||||
}
|
||||
|
||||
func JapaneseToRomaji(text string) string {
|
||||
if !ContainsJapanese(text) {
|
||||
return text
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
runes := []rune(text)
|
||||
i := 0
|
||||
|
||||
for i < len(runes) {
|
||||
|
||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||
nextRomaji := ""
|
||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||
nextRomaji = romaji
|
||||
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if i < len(runes)-1 {
|
||||
combo := string(runes[i : i+2])
|
||||
if romaji, ok := combinationHiragana[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if romaji, ok := combinationKatakana[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
r := runes[i]
|
||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if isKanji(r) {
|
||||
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
|
||||
result.WriteRune(r)
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func BuildSearchQuery(trackName, artistName string) string {
|
||||
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
artistRomaji := JapaneseToRomaji(artistName)
|
||||
|
||||
trackClean := cleanSearchQuery(trackRomaji)
|
||||
artistClean := cleanSearchQuery(artistRomaji)
|
||||
|
||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||
}
|
||||
|
||||
func cleanSearchQuery(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||
result.WriteRune(r)
|
||||
} else if r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func cleanToASCII(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
} else if r == ',' || r == '.' {
|
||||
|
||||
result.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
+36
-11
@@ -795,7 +795,14 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||
var cover interface{}
|
||||
if coverObj != nil {
|
||||
cover = getString(coverObj, "medium")
|
||||
|
||||
cover = getString(coverObj, "small")
|
||||
if cover == "" {
|
||||
cover = getString(coverObj, "medium")
|
||||
}
|
||||
if cover == "" {
|
||||
cover = getString(coverObj, "large")
|
||||
}
|
||||
}
|
||||
|
||||
tracks := []map[string]interface{}{}
|
||||
@@ -1042,6 +1049,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
albumData := getMap(trackData, "albumOfTrack")
|
||||
albumName := ""
|
||||
albumID := ""
|
||||
albumArtistsString := ""
|
||||
var trackCover interface{}
|
||||
|
||||
if len(albumData) > 0 {
|
||||
@@ -1053,21 +1061,38 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
}
|
||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||
if coverObj != nil {
|
||||
|
||||
trackCover = getString(coverObj, "small")
|
||||
if trackCover == "" {
|
||||
trackCover = getString(coverObj, "medium")
|
||||
}
|
||||
if trackCover == "" {
|
||||
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{}{
|
||||
"id": trackID,
|
||||
"cover": trackCover,
|
||||
"title": getString(trackData, "name"),
|
||||
"artist": artistsString,
|
||||
"artistIds": artistIDs,
|
||||
"plays": rank,
|
||||
"status": status,
|
||||
"album": albumName,
|
||||
"albumId": albumID,
|
||||
"duration": durationString,
|
||||
"id": trackID,
|
||||
"cover": trackCover,
|
||||
"title": getString(trackData, "name"),
|
||||
"artist": artistsString,
|
||||
"artistIds": artistIDs,
|
||||
"plays": rank,
|
||||
"status": status,
|
||||
"album": albumName,
|
||||
"albumArtist": albumArtistsString,
|
||||
"albumId": albumID,
|
||||
"duration": durationString,
|
||||
}
|
||||
tracks = append(tracks, trackInfo)
|
||||
}
|
||||
|
||||
+53
-14
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -44,6 +46,7 @@ type TrackMetadata struct {
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
Plays string `json:"plays,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistSimple struct {
|
||||
@@ -75,6 +78,7 @@ type AlbumTrackMetadata struct {
|
||||
ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
|
||||
Plays string `json:"plays,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
@@ -225,16 +229,17 @@ type apiPlaylistResponse struct {
|
||||
Count int `json:"count"`
|
||||
Followers int `json:"followers"`
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
Cover string `json:"cover"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Plays string `json:"plays"`
|
||||
Status string `json:"status"`
|
||||
Album string `json:"album"`
|
||||
AlbumID string `json:"albumId"`
|
||||
Duration string `json:"duration"`
|
||||
ID string `json:"id"`
|
||||
Cover string `json:"cover"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Plays string `json:"plays"`
|
||||
Status string `json:"status"`
|
||||
Album string `json:"album"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
@@ -808,12 +813,12 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
||||
|
||||
externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID)
|
||||
|
||||
coverURL := raw.Cover.Medium
|
||||
coverURL := raw.Cover.Small
|
||||
if coverURL == "" {
|
||||
coverURL = raw.Cover.Large
|
||||
coverURL = raw.Cover.Medium
|
||||
}
|
||||
if coverURL == "" {
|
||||
coverURL = raw.Cover.Small
|
||||
coverURL = raw.Cover.Large
|
||||
}
|
||||
|
||||
releaseDate := raw.Album.Released
|
||||
@@ -942,7 +947,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
||||
Artists: item.Artist,
|
||||
Name: item.Title,
|
||||
AlbumName: item.Album,
|
||||
AlbumArtist: item.Artist,
|
||||
AlbumArtist: item.AlbumArtist,
|
||||
DurationMS: durationMS,
|
||||
Images: item.Cover,
|
||||
ReleaseDate: "",
|
||||
@@ -1400,3 +1405,37 @@ func SearchSpotifyByType(ctx context.Context, query string, searchType string, l
|
||||
client := NewSpotifyMetadataClient()
|
||||
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
-225
@@ -25,13 +25,6 @@ type TidalDownloader struct {
|
||||
apiURL string
|
||||
}
|
||||
|
||||
type TidalSearchResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
TotalNumberOfItems int `json:"totalNumberOfItems"`
|
||||
Items []TidalTrack `json:"items"`
|
||||
}
|
||||
|
||||
type TidalTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -181,184 +174,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) {
|
||||
return t.SearchTracksWithLimit(query, 50)
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTracksWithLimit(query string, limit int) (*TidalSearchResponse, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=%d&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query), limit)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result TidalSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string, expectedDuration int) (*TidalTrack, error) {
|
||||
|
||||
queries := []string{}
|
||||
|
||||
if artistName != "" && trackName != "" {
|
||||
queries = append(queries, artistName+" "+trackName)
|
||||
}
|
||||
|
||||
if trackName != "" {
|
||||
queries = append(queries, trackName)
|
||||
}
|
||||
|
||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||
|
||||
romajiTrack := JapaneseToRomaji(trackName)
|
||||
romajiArtist := JapaneseToRomaji(artistName)
|
||||
|
||||
cleanRomajiTrack := cleanToASCII(romajiTrack)
|
||||
cleanRomajiArtist := cleanToASCII(romajiArtist)
|
||||
|
||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||
if !containsQuery(queries, romajiQuery) {
|
||||
queries = append(queries, romajiQuery)
|
||||
fmt.Printf("Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||
}
|
||||
}
|
||||
|
||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||
if !containsQuery(queries, cleanRomajiTrack) {
|
||||
queries = append(queries, cleanRomajiTrack)
|
||||
}
|
||||
}
|
||||
|
||||
if artistName != "" && cleanRomajiTrack != "" {
|
||||
partialQuery := artistName + " " + cleanRomajiTrack
|
||||
if !containsQuery(queries, partialQuery) {
|
||||
queries = append(queries, partialQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if artistName != "" {
|
||||
artistOnly := cleanToASCII(JapaneseToRomaji(artistName))
|
||||
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
||||
queries = append(queries, artistOnly)
|
||||
}
|
||||
}
|
||||
|
||||
var allTracks []TidalTrack
|
||||
searchedQueries := make(map[string]bool)
|
||||
|
||||
for _, query := range queries {
|
||||
cleanQuery := strings.TrimSpace(query)
|
||||
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
||||
continue
|
||||
}
|
||||
searchedQueries[cleanQuery] = true
|
||||
|
||||
fmt.Printf("Searching Tidal for: %s\n", cleanQuery)
|
||||
|
||||
result, err := t.SearchTracksWithLimit(cleanQuery, 100)
|
||||
if err != nil {
|
||||
fmt.Printf("Search error for '%s': %v\n", cleanQuery, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(result.Items) > 0 {
|
||||
fmt.Printf("Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||
allTracks = append(allTracks, result.Items...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allTracks) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found for any search query")
|
||||
}
|
||||
|
||||
var bestMatch *TidalTrack
|
||||
if expectedDuration > 0 {
|
||||
tolerance := 3
|
||||
var durationMatches []*TidalTrack
|
||||
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
durationDiff := track.Duration - expectedDuration
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff <= tolerance {
|
||||
durationMatches = append(durationMatches, track)
|
||||
}
|
||||
}
|
||||
|
||||
if len(durationMatches) > 0 {
|
||||
|
||||
bestMatch = durationMatches[0]
|
||||
for _, track := range durationMatches {
|
||||
for _, tag := range track.MediaMetadata.Tags {
|
||||
if tag == "HIRES_LOSSLESS" {
|
||||
bestMatch = track
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("Found via duration match: %s - %s (%s)\n",
|
||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
||||
return bestMatch, nil
|
||||
}
|
||||
}
|
||||
|
||||
bestMatch = &allTracks[0]
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
for _, tag := range track.MediaMetadata.Tags {
|
||||
if tag == "HIRES_LOSSLESS" {
|
||||
bestMatch = track
|
||||
break
|
||||
}
|
||||
}
|
||||
if bestMatch != &allTracks[0] {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
||||
|
||||
return bestMatch, nil
|
||||
}
|
||||
|
||||
func containsQuery(queries []string, query string) bool {
|
||||
for _, q := range queries {
|
||||
if q == query {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
@@ -925,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)
|
||||
}
|
||||
|
||||
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 {
|
||||
XMLName xml.Name `xml:"MPD"`
|
||||
Period struct {
|
||||
AdaptationSet struct {
|
||||
Representation struct {
|
||||
SegmentTemplate struct {
|
||||
Initialization string `xml:"initialization,attr"`
|
||||
Media string `xml:"media,attr"`
|
||||
Timeline struct {
|
||||
Segments []struct {
|
||||
Duration int `xml:"d,attr"`
|
||||
Repeat int `xml:"r,attr"`
|
||||
} `xml:"S"`
|
||||
} `xml:"SegmentTimeline"`
|
||||
} `xml:"SegmentTemplate"`
|
||||
AdaptationSets []struct {
|
||||
MimeType string `xml:"mimeType,attr"`
|
||||
Codecs string `xml:"codecs,attr"`
|
||||
Representations []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Codecs string `xml:"codecs,attr"`
|
||||
Bandwidth int `xml:"bandwidth,attr"`
|
||||
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||
} `xml:"Representation"`
|
||||
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
||||
} `xml:"AdaptationSet"`
|
||||
} `xml:"Period"`
|
||||
}
|
||||
|
||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
||||
|
||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
|
||||
@@ -954,8 +776,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
|
||||
manifestStr := string(manifestBytes)
|
||||
|
||||
if strings.HasPrefix(manifestStr, "{") {
|
||||
|
||||
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
|
||||
var btsManifest TidalBTSManifest
|
||||
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
||||
@@ -972,25 +793,78 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
fmt.Println("Manifest: DASH format")
|
||||
|
||||
var mpd MPD
|
||||
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
||||
var segTemplate *SegmentTemplate
|
||||
|
||||
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
|
||||
initURL = segTemplate.Initialization
|
||||
mediaTemplate := segTemplate.Media
|
||||
var mediaTemplate string
|
||||
segmentCount := 0
|
||||
|
||||
if initURL == "" || mediaTemplate == "" {
|
||||
if segTemplate != nil {
|
||||
initURL = segTemplate.Initialization
|
||||
mediaTemplate = segTemplate.Media
|
||||
|
||||
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
||||
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
||||
|
||||
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||
initURL = match[1]
|
||||
for _, seg := range segTemplate.Timeline.Segments {
|
||||
segmentCount += seg.Repeat + 1
|
||||
}
|
||||
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||
mediaTemplate = match[1]
|
||||
}
|
||||
|
||||
if segmentCount > 0 && initURL != "" && mediaTemplate != "" {
|
||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||
|
||||
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 == "" {
|
||||
@@ -1000,23 +874,26 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||
|
||||
segmentCount := 0
|
||||
for _, seg := range segTemplate.Timeline.Segments {
|
||||
segmentCount += seg.Repeat + 1
|
||||
segmentCount = 0
|
||||
|
||||
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 {
|
||||
segRe := regexp.MustCompile(`<S d="\d+"(?: r="(\d+)")?`)
|
||||
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
|
||||
}
|
||||
return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
||||
}
|
||||
|
||||
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
|
||||
|
||||
for i := 1; i <= segmentCount; i++ {
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
|
||||
+20
-21
@@ -1,23 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
+19
-14
@@ -1,16 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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">
|
||||
<title>SpotiFLAC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
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"
|
||||
rel="stylesheet">
|
||||
<title>SpotiFLAC</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -27,7 +27,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.25.0",
|
||||
"motion": "^12.26.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.6",
|
||||
"@types/node": "^25.0.8",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
@@ -48,7 +48,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.52.0",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
6f2a6dc27f7d8d215283f6d07b4eaa54
|
||||
68754ba75ba7fe058dd9ebf6593e2759
|
||||
Generated
+175
-175
@@ -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)
|
||||
'@tailwindcss/vite':
|
||||
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:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -54,8 +54,8 @@ importers:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@19.2.3)
|
||||
motion:
|
||||
specifier: ^12.25.0
|
||||
version: 12.25.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
specifier: ^12.26.2
|
||||
version: 12.26.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
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
|
||||
version: 9.39.2
|
||||
'@types/node':
|
||||
specifier: ^25.0.6
|
||||
version: 25.0.6
|
||||
specifier: ^25.0.8
|
||||
version: 25.0.8
|
||||
'@types/react':
|
||||
specifier: ^19.2.8
|
||||
version: 19.2.8
|
||||
@@ -89,7 +89,7 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.8)
|
||||
'@vitejs/plugin-react':
|
||||
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:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
@@ -112,50 +112,50 @@ importers:
|
||||
specifier: ~5.9.3
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.52.0
|
||||
version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: ^8.53.0
|
||||
version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
vite:
|
||||
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:
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||
'@babel/code-frame@7.28.6':
|
||||
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/compat-data@7.28.5':
|
||||
resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==}
|
||||
'@babel/compat-data@7.28.6':
|
||||
resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/core@7.28.5':
|
||||
resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==}
|
||||
'@babel/core@7.28.6':
|
||||
resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/generator@7.28.5':
|
||||
resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==}
|
||||
'@babel/generator@7.28.6':
|
||||
resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-compilation-targets@7.27.2':
|
||||
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
|
||||
'@babel/helper-compilation-targets@7.28.6':
|
||||
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-globals@7.28.0':
|
||||
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-module-imports@7.27.1':
|
||||
resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
|
||||
'@babel/helper-module-imports@7.28.6':
|
||||
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-module-transforms@7.28.3':
|
||||
resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==}
|
||||
'@babel/helper-module-transforms@7.28.6':
|
||||
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
|
||||
'@babel/helper-plugin-utils@7.27.1':
|
||||
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
|
||||
'@babel/helper-plugin-utils@7.28.6':
|
||||
resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
@@ -170,12 +170,12 @@ packages:
|
||||
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helpers@7.28.4':
|
||||
resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
|
||||
'@babel/helpers@7.28.6':
|
||||
resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.28.5':
|
||||
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
|
||||
'@babel/parser@7.28.6':
|
||||
resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -191,16 +191,16 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.28.5':
|
||||
resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==}
|
||||
'@babel/traverse@7.28.6':
|
||||
resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.28.5':
|
||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||
'@babel/types@7.28.6':
|
||||
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@emnapi/runtime@1.8.1':
|
||||
@@ -1259,8 +1259,8 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/node@25.0.6':
|
||||
resolution: {integrity: sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==}
|
||||
'@types/node@25.0.8':
|
||||
resolution: {integrity: sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
@@ -1270,63 +1270,63 @@ packages:
|
||||
'@types/react@19.2.8':
|
||||
resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.52.0':
|
||||
resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==}
|
||||
'@typescript-eslint/eslint-plugin@8.53.0':
|
||||
resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.52.0
|
||||
'@typescript-eslint/parser': ^8.53.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.52.0':
|
||||
resolution: {integrity: sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==}
|
||||
'@typescript-eslint/parser@8.53.0':
|
||||
resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.52.0':
|
||||
resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==}
|
||||
'@typescript-eslint/project-service@8.53.0':
|
||||
resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.52.0':
|
||||
resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==}
|
||||
'@typescript-eslint/scope-manager@8.53.0':
|
||||
resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.52.0':
|
||||
resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==}
|
||||
'@typescript-eslint/tsconfig-utils@8.53.0':
|
||||
resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.52.0':
|
||||
resolution: {integrity: sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==}
|
||||
'@typescript-eslint/type-utils@8.53.0':
|
||||
resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/types@8.52.0':
|
||||
resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==}
|
||||
'@typescript-eslint/types@8.53.0':
|
||||
resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.52.0':
|
||||
resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==}
|
||||
'@typescript-eslint/typescript-estree@8.53.0':
|
||||
resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.52.0':
|
||||
resolution: {integrity: sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==}
|
||||
'@typescript-eslint/utils@8.53.0':
|
||||
resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.52.0':
|
||||
resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==}
|
||||
'@typescript-eslint/visitor-keys@8.53.0':
|
||||
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitejs/plugin-react@5.1.2':
|
||||
@@ -1540,8 +1540,8 @@ packages:
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
|
||||
framer-motion@12.25.0:
|
||||
resolution: {integrity: sha512-mlWqd0rApIjeyhTCSNCqPYsUAEhkcUukZxH3ke6KbstBRPcxhEpuIjmiUQvB+1E9xkEm5SpNHBgHCapH/QHTWg==}
|
||||
framer-motion@12.26.2:
|
||||
resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
@@ -1751,14 +1751,14 @@ packages:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
motion-dom@12.24.11:
|
||||
resolution: {integrity: sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==}
|
||||
motion-dom@12.26.2:
|
||||
resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==}
|
||||
|
||||
motion-utils@12.24.10:
|
||||
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
|
||||
|
||||
motion@12.25.0:
|
||||
resolution: {integrity: sha512-jBFohEYklpZ+TL64zv03sHdqr1Tsc8/yDy7u68hVzi7hTJYtv53AduchqCiY3aWi4vY1hweS8DWtgCuckusYdQ==}
|
||||
motion@12.26.2:
|
||||
resolution: {integrity: sha512-2Q6g0zK1gUJKhGT742DAe42LgietcdiJ3L3OcYAHCQaC1UkLnn6aC8S/obe4CxYTLAgid2asS1QdQ/blYfo5dw==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
@@ -1958,8 +1958,8 @@ packages:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
typescript-eslint@8.52.0:
|
||||
resolution: {integrity: sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==}
|
||||
typescript-eslint@8.53.0:
|
||||
resolution: {integrity: sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
@@ -2069,25 +2069,25 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/code-frame@7.27.1':
|
||||
'@babel/code-frame@7.28.6':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
js-tokens: 4.0.0
|
||||
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:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/generator': 7.28.5
|
||||
'@babel/helper-compilation-targets': 7.27.2
|
||||
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5)
|
||||
'@babel/helpers': 7.28.4
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/traverse': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/code-frame': 7.28.6
|
||||
'@babel/generator': 7.28.6
|
||||
'@babel/helper-compilation-targets': 7.28.6
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6)
|
||||
'@babel/helpers': 7.28.6
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/traverse': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3
|
||||
@@ -2097,17 +2097,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/generator@7.28.5':
|
||||
'@babel/generator@7.28.6':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
|
||||
'@babel/helper-compilation-targets@7.27.2':
|
||||
'@babel/helper-compilation-targets@7.28.6':
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.28.5
|
||||
'@babel/compat-data': 7.28.6
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
browserslist: 4.28.1
|
||||
lru-cache: 5.1.1
|
||||
@@ -2115,23 +2115,23 @@ snapshots:
|
||||
|
||||
'@babel/helper-globals@7.28.0': {}
|
||||
|
||||
'@babel/helper-module-imports@7.27.1':
|
||||
'@babel/helper-module-imports@7.28.6':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/traverse': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/helper-module-imports': 7.27.1
|
||||
'@babel/core': 7.28.6
|
||||
'@babel/helper-module-imports': 7.28.6
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/traverse': 7.28.5
|
||||
'@babel/traverse': 7.28.6
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-plugin-utils@7.27.1': {}
|
||||
'@babel/helper-plugin-utils@7.28.6': {}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
@@ -2139,44 +2139,44 @@ snapshots:
|
||||
|
||||
'@babel/helper-validator-option@7.27.1': {}
|
||||
|
||||
'@babel/helpers@7.28.4':
|
||||
'@babel/helpers@7.28.6':
|
||||
dependencies:
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
|
||||
'@babel/parser@7.28.5':
|
||||
'@babel/parser@7.28.6':
|
||||
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:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
'@babel/core': 7.28.6
|
||||
'@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:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
'@babel/core': 7.28.6
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/code-frame': 7.28.6
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
|
||||
'@babel/traverse@7.28.5':
|
||||
'@babel/traverse@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
'@babel/generator': 7.28.5
|
||||
'@babel/code-frame': 7.28.6
|
||||
'@babel/generator': 7.28.6
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/template': 7.27.2
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/types@7.28.5':
|
||||
'@babel/types@7.28.6':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -3016,39 +3016,39 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-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:
|
||||
'@tailwindcss/node': 4.1.18
|
||||
'@tailwindcss/oxide': 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':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
'@types/babel__generator': 7.27.0
|
||||
'@types/babel__template': 7.4.4
|
||||
'@types/babel__traverse': 7.28.0
|
||||
|
||||
'@types/babel__generator@7.27.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/types': 7.28.6
|
||||
|
||||
'@types/babel__template@7.4.4':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
|
||||
'@types/babel__traverse@7.28.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.5
|
||||
'@babel/types': 7.28.6
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/node@25.0.6':
|
||||
'@types/node@25.0.8':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
@@ -3060,14 +3060,14 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
'@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/scope-manager': 8.52.0
|
||||
'@typescript-eslint/type-utils': 8.52.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/visitor-keys': 8.52.0
|
||||
'@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.53.0
|
||||
'@typescript-eslint/type-utils': 8.53.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.53.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
@@ -3076,41 +3076,41 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/scope-manager': 8.52.0
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.52.0
|
||||
'@typescript-eslint/scope-manager': 8.53.0
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.53.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.52.0':
|
||||
'@typescript-eslint/scope-manager@8.53.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/visitor-keys': 8.52.0
|
||||
'@typescript-eslint/types': 8.53.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:
|
||||
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:
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/typescript-estree': 8.52.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/types': 8.53.0
|
||||
'@typescript-eslint/typescript-estree': 8.53.0(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
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
@@ -3118,14 +3118,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/project-service': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/visitor-keys': 8.52.0
|
||||
'@typescript-eslint/project-service': 8.53.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
'@typescript-eslint/visitor-keys': 8.53.0
|
||||
debug: 4.4.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.7.3
|
||||
@@ -3135,31 +3135,31 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.52.0
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.53.0
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
'@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.52.0':
|
||||
'@typescript-eslint/visitor-keys@8.53.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/types': 8.53.0
|
||||
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:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
|
||||
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5)
|
||||
'@babel/core': 7.28.6
|
||||
'@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.6)
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
'@types/babel__core': 7.20.5
|
||||
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:
|
||||
- supports-color
|
||||
|
||||
@@ -3292,8 +3292,8 @@ snapshots:
|
||||
|
||||
eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/parser': 7.28.5
|
||||
'@babel/core': 7.28.6
|
||||
'@babel/parser': 7.28.6
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.3.5
|
||||
@@ -3399,9 +3399,9 @@ snapshots:
|
||||
|
||||
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:
|
||||
motion-dom: 12.24.11
|
||||
motion-dom: 12.26.2
|
||||
motion-utils: 12.24.10
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
@@ -3554,15 +3554,15 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
motion-dom@12.24.11:
|
||||
motion-dom@12.26.2:
|
||||
dependencies:
|
||||
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:
|
||||
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
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
@@ -3768,12 +3768,12 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
'@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/parser': 8.52.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/utils': 8.52.0(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.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.53.0(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)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -3808,7 +3808,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -3817,7 +3817,7 @@ snapshots:
|
||||
rollup: 4.55.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.6
|
||||
'@types/node': 25.0.8
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
|
||||
@@ -2,32 +2,23 @@ import sharp from 'sharp';
|
||||
import { readFileSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..', '..');
|
||||
|
||||
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
|
||||
const outputPath = join(rootDir, 'build', 'appicon.png');
|
||||
|
||||
async function generateIcon() {
|
||||
try {
|
||||
// Ensure build directory exists
|
||||
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
||||
|
||||
// Read SVG
|
||||
const svgBuffer = readFileSync(svgPath);
|
||||
|
||||
// Convert SVG to PNG (1024x1024 for Wails)
|
||||
await sharp(svgBuffer)
|
||||
.resize(1024, 1024)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log('✓ Icon generated:', outputPath);
|
||||
} catch (error) {
|
||||
console.error('✗ Failed to generate icon:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
||||
const svgBuffer = readFileSync(svgPath);
|
||||
await sharp(svgBuffer)
|
||||
.resize(1024, 1024)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
console.log('✓ Icon generated:', outputPath);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('✗ Failed to generate icon:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateIcon();
|
||||
|
||||
+164
-155
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, X, ArrowUp } from "lucide-react";
|
||||
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 { OpenFolder } from "../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
@@ -50,22 +50,31 @@ function App() {
|
||||
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
||||
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const CURRENT_VERSION = "7.0.2";
|
||||
const CURRENT_VERSION = "7.0.5";
|
||||
const download = useDownload();
|
||||
const metadata = useMetadata();
|
||||
const lyrics = useLyrics();
|
||||
const cover = useCover();
|
||||
const availability = useAvailability();
|
||||
const downloadQueue = useDownloadQueueDialog();
|
||||
useLayoutEffect(() => {
|
||||
const savedSettings = getSettings();
|
||||
if (savedSettings) {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
applyFont(savedSettings.fontFamily);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initSettings = async () => {
|
||||
const settings = getSettings();
|
||||
const settings = await loadSettings();
|
||||
applyThemeMode(settings.themeMode);
|
||||
applyTheme(settings.theme);
|
||||
applyFont(settings.fontFamily);
|
||||
if (!settings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
saveSettings(settingsWithDefaults);
|
||||
await saveSettings(settingsWithDefaults);
|
||||
}
|
||||
};
|
||||
initSettings();
|
||||
@@ -190,7 +199,7 @@ function App() {
|
||||
url: spotifyUrl,
|
||||
type: "album",
|
||||
name: album_info.name,
|
||||
artist: `${album_info.total_tracks} tracks`,
|
||||
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
|
||||
image: album_info.images,
|
||||
};
|
||||
}
|
||||
@@ -200,7 +209,7 @@ function App() {
|
||||
url: spotifyUrl,
|
||||
type: "playlist",
|
||||
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 || "",
|
||||
};
|
||||
}
|
||||
@@ -210,7 +219,7 @@ function App() {
|
||||
url: spotifyUrl,
|
||||
type: "artist",
|
||||
name: artist_info.name,
|
||||
artist: `${artist_info.total_albums} albums`,
|
||||
artist: `${artist_info.total_albums.toLocaleString()} albums`,
|
||||
image: artist_info.images,
|
||||
};
|
||||
}
|
||||
@@ -253,49 +262,49 @@ function App() {
|
||||
return null;
|
||||
if ("track" in 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) {
|
||||
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) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
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);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}} />);
|
||||
}
|
||||
if ("playlist_info" in 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) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
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);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}} />);
|
||||
}
|
||||
if ("artist_info" in 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) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}/>);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}} />);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -328,7 +337,7 @@ function App() {
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "settings":
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn} />;
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "audio-analysis":
|
||||
@@ -339,133 +348,133 @@ function App() {
|
||||
return <FileManagerPage />;
|
||||
default:
|
||||
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.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>
|
||||
<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>
|
||||
|
||||
<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);
|
||||
const updatedUrl = await metadata.handleFetchMetadata(url);
|
||||
if (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>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<TitleBar />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<TitleBar />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
|
||||
|
||||
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{renderPage()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{renderPage()}
|
||||
</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>
|
||||
|
||||
|
||||
<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>);
|
||||
}
|
||||
export default App;
|
||||
|
||||
@@ -90,7 +90,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
<span>{albumInfo.release_date}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
|
||||
{albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -415,7 +415,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
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";
|
||||
interface DownloadQueueProps {
|
||||
isOpen: boolean;
|
||||
@@ -47,6 +48,17 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
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) => {
|
||||
switch (status) {
|
||||
case "downloading":
|
||||
@@ -72,8 +84,8 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
queued: "outline",
|
||||
};
|
||||
return (<Badge variant={variants[status] || "outline"} className="text-xs">
|
||||
{status}
|
||||
</Badge>);
|
||||
{status}
|
||||
</Badge>);
|
||||
};
|
||||
const formatDuration = (startTimestamp: number) => {
|
||||
if (startTimestamp === 0)
|
||||
@@ -94,138 +106,138 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
}
|
||||
};
|
||||
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">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
|
||||
<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}>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
Clear History
|
||||
</Button>)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<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">
|
||||
{(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"/>
|
||||
Clear History
|
||||
</Button>)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Queued:</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 className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Queued:</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 className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Downloaded:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Speed:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||
|
||||
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Downloaded:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Speed:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
</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 items-start justify-between gap-2 mb-1">
|
||||
<div className="flex-1 min-w-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>
|
||||
<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>
|
||||
|
||||
|
||||
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
||||
<span>
|
||||
{item.progress > 0
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex-1 min-w-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`
|
||||
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
||||
? "Downloading..."
|
||||
: "Starting..."}
|
||||
</span>
|
||||
<span>
|
||||
{item.speed > 0
|
||||
</span>
|
||||
<span>
|
||||
{item.speed > 0
|
||||
? `${item.speed.toFixed(2)} MB/s`
|
||||
: queueInfo.current_speed > 0
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>)}
|
||||
</span>
|
||||
</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 === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
File already exists
|
||||
</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 === "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 === "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>
|
||||
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
File already exists
|
||||
</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 === "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>
|
||||
</DialogContent>
|
||||
</Dialog>);
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
</div>
|
||||
<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>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||
@@ -110,7 +110,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -8,354 +8,433 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
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 { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||
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">
|
||||
<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>
|
||||
</svg>);
|
||||
<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>
|
||||
</svg>);
|
||||
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="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>);
|
||||
<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>
|
||||
</svg>);
|
||||
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="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>);
|
||||
<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>
|
||||
</svg>);
|
||||
interface SettingsPageProps {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
}
|
||||
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||
const resetToSaved = useCallback(() => {
|
||||
const freshSavedSettings = getSettings();
|
||||
flushSync(() => {
|
||||
setTempSettings(freshSavedSettings);
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (onResetRequest) {
|
||||
onResetRequest(resetToSaved);
|
||||
}
|
||||
}, [onResetRequest, resetToSaved]);
|
||||
useEffect(() => {
|
||||
onUnsavedChangesChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onUnsavedChangesChange]);
|
||||
useEffect(() => {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showFFmpegWarning, setShowFFmpegWarning] = useState(false);
|
||||
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
|
||||
const [installProgress, setInstallProgress] = useState(0);
|
||||
const downloadProgress = useDownloadProgress();
|
||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||
const resetToSaved = useCallback(() => {
|
||||
const freshSavedSettings = getSettings();
|
||||
flushSync(() => {
|
||||
setTempSettings(freshSavedSettings);
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (onResetRequest) {
|
||||
onResetRequest(resetToSaved);
|
||||
}
|
||||
}, [onResetRequest, resetToSaved]);
|
||||
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);
|
||||
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 () => {
|
||||
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");
|
||||
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);
|
||||
await saveSettings(settingsWithDefaults);
|
||||
}
|
||||
};
|
||||
const handleBrowseFolder = async () => {
|
||||
try {
|
||||
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||
if (selectedPath && selectedPath.trim() !== "") {
|
||||
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||
}
|
||||
loadDefaults();
|
||||
}, []);
|
||||
const handleSave = async () => {
|
||||
await saveSettings(tempSettings);
|
||||
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);
|
||||
toast.error(`Error selecting folder: ${error}`);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error checking FFmpeg:", error);
|
||||
}
|
||||
}
|
||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||
};
|
||||
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="space-y-4">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<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"/>
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<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" />
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue placeholder="Select theme mode"/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue placeholder="Select theme mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</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>
|
||||
</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"/>
|
||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</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>))}
|
||||
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Select>)}
|
||||
|
||||
|
||||
<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"/>
|
||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
||||
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
||||
</SelectItem>))}
|
||||
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
|
||||
<SelectItem value="7">FLAC 24-bit (Studio Quality)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Select>)}
|
||||
|
||||
|
||||
<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 }))}/>
|
||||
{tempSettings.downloader === "amazon" && (
|
||||
<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">
|
||||
16-bit/44.1kHz or 24-bit/48kHz+
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<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 className="flex items-center gap-6">
|
||||
<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-6">
|
||||
<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 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 className="border-t"/>
|
||||
<div className="border-t" />
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</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"/>)}
|
||||
</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>)}
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</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" />)}
|
||||
</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="flex items-center gap-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</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"/>)}
|
||||
</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>)}
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</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" />)}
|
||||
</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 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>
|
||||
|
||||
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<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>);
|
||||
}
|
||||
|
||||
@@ -95,12 +95,12 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?labels=bug&body=%23%23%23%20Problem%0AExplain%20the%20issue%20briefly.%0A%0A%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%20Spotify%20URL%0APaste%20the%20link%20here.%0A%0A%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS")}>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?title=%5BBug%20Report%5D%20/%20%5BFeature%20Request%5D&body=%3C%21--%20WARNING%3A%20Issues%20that%20do%20not%20follow%20this%20template%20will%20be%20closed%20without%20review.%20Fill%20out%20the%20relevant%20section%20and%20delete%20the%20other.%20--%3E%0A%0A%23%23%23%20%5BBug%20Report%5D%0A%0A%23%23%23%23%20Problem%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%23%20Spotify%20URL%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Version%0ASpotiFLAC%20v%0A%0A%23%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot%0A%0A---%0A%0A%23%23%23%20%5BFeature%20Request%5D%0A%0A%23%23%23%23%20Description%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Use%20Case%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot")}>
|
||||
<GithubIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bug</p>
|
||||
<p>Report Bug or Feature Request</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & {
|
||||
album_name: string;
|
||||
@@ -32,6 +33,7 @@ interface TrackInfoProps {
|
||||
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) {
|
||||
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
@@ -44,96 +46,106 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
return num.toLocaleString();
|
||||
};
|
||||
return (<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="shrink-0">
|
||||
{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"/>
|
||||
<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)}
|
||||
</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>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="shrink-0">
|
||||
{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"/>
|
||||
<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)}
|
||||
</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 && 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>
|
||||
</Card>);
|
||||
<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 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>);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { 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 { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
@@ -52,6 +53,7 @@ interface TrackListProps {
|
||||
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) {
|
||||
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||
let filteredTracks = tracks.filter((track) => {
|
||||
if (!searchQuery)
|
||||
return true;
|
||||
@@ -118,6 +120,35 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
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 allSelected = tracksWithIsrc.length > 0 &&
|
||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
||||
@@ -135,192 +166,204 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
return num.toLocaleString();
|
||||
};
|
||||
return (<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
||||
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||
Title
|
||||
</th>
|
||||
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||
Album
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||
Duration
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
||||
Plays
|
||||
</th>
|
||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||
{showCheckboxes && (<td className="p-4 align-middle">
|
||||
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span>{startIndex + index + 1}</span>
|
||||
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
||||
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||
Title
|
||||
</th>
|
||||
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||
Album
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||
Duration
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
||||
Plays
|
||||
</th>
|
||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||
{showCheckboxes && (<td className="p-4 align-middle">
|
||||
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span>{startIndex + index + 1}</span>
|
||||
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
||||
? "text-green-500"
|
||||
: track.status === "DOWN"
|
||||
? "text-red-500"
|
||||
: track.status === "NEW"
|
||||
? "text-blue-500"
|
||||
: ""}`}>
|
||||
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
||||
</span>)}
|
||||
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
||||
</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>
|
||||
</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>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||
const artistNames = track.artists.split(", ").map(name => name.trim());
|
||||
return artistNames.map((name, i) => {
|
||||
const artistData = track.artists_data![i];
|
||||
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
||||
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,
|
||||
name: name,
|
||||
external_urls: artistData.external_urls,
|
||||
})}>
|
||||
{name}
|
||||
</span>) : (name)}
|
||||
{i < artistNames.length - 1 && ", "}
|
||||
</span>);
|
||||
{name}
|
||||
</span>) : (name)}
|
||||
{i < artistNames.length - 1 && ", "}
|
||||
</span>);
|
||||
});
|
||||
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
id: track.artist_id!,
|
||||
name: track.artists,
|
||||
external_urls: track.artist_url!,
|
||||
})}>
|
||||
{track.artists}
|
||||
</span>) : (track.artists)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{!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({
|
||||
{track.artists}
|
||||
</span>) : (track.artists)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{!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({
|
||||
id: track.album_id!,
|
||||
name: track.album_name,
|
||||
external_urls: track.album_url!,
|
||||
})}>
|
||||
{track.album_name}
|
||||
</span>) : (track.album_name)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
||||
{track.plays ? formatPlays(track.plays) : ""}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{track.isrc && (<Tooltip>
|
||||
<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}>
|
||||
{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>
|
||||
</TooltipTrigger>
|
||||
<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>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||
<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}>
|
||||
{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={() => {
|
||||
{track.album_name}
|
||||
</span>) : (track.album_name)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
||||
{track.plays ? formatPlays(track.plays) : ""}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{track.isrc && (<Tooltip>
|
||||
<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="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"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<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>)}
|
||||
</TooltipContent>
|
||||
</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>
|
||||
<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="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}`;
|
||||
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}`)}>
|
||||
{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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="sm" 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"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{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"}`}/>
|
||||
<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"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}} 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"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{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"}`}/>
|
||||
<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"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="#" onClick={(e) => {
|
||||
{totalPages > 1 && (<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1)
|
||||
onPageChange(currentPage - 1);
|
||||
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}>
|
||||
<PaginationLink href="#" onClick={(e) => {
|
||||
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>) : (<PaginationItem key={page}>
|
||||
<PaginationLink href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
}} isActive={currentPage === page} className="cursor-pointer">
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>))}
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>)))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" onClick={(e) => {
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages)
|
||||
onPageChange(currentPage + 1);
|
||||
}} className={currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>)}
|
||||
</div>);
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
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",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
icon: "h-9 w-9 p-0",
|
||||
"icon-sm": "h-8 w-8 p-0",
|
||||
"icon-lg": "h-10 w-10 p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -35,7 +35,9 @@ export function useCover() {
|
||||
track: position,
|
||||
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));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
@@ -129,7 +131,9 @@ export function useCover() {
|
||||
track: trackPosition,
|
||||
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));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
|
||||
@@ -44,7 +44,7 @@ export function useDownload() {
|
||||
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 service = settings.downloader;
|
||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
@@ -82,7 +82,9 @@ export function useDownload() {
|
||||
year: yearValue,
|
||||
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));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
@@ -346,7 +348,8 @@ export function useDownload() {
|
||||
year: yearValue,
|
||||
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));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
@@ -575,7 +578,8 @@ export function useDownload() {
|
||||
setDownloadProgress(0);
|
||||
let outputDir = settings.downloadPath;
|
||||
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));
|
||||
}
|
||||
const selectedTrackObjects = selectedTracks
|
||||
@@ -723,7 +727,8 @@ export function useDownload() {
|
||||
setDownloadProgress(0);
|
||||
let outputDir = settings.downloadPath;
|
||||
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));
|
||||
}
|
||||
logger.info(`checking existing files in parallel...`);
|
||||
|
||||
@@ -32,7 +32,9 @@ export function useLyrics() {
|
||||
track: position,
|
||||
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));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
@@ -125,7 +127,9 @@ export function useLyrics() {
|
||||
track: trackPosition,
|
||||
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));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
|
||||
@@ -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
@@ -26,6 +26,7 @@
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: "Bricolage Grotesque", "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -75,11 +76,15 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -134,43 +139,51 @@
|
||||
/* 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] * {
|
||||
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] * {
|
||||
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] * {
|
||||
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] * {
|
||||
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 [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] * {
|
||||
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] * {
|
||||
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] * {
|
||||
color: rgb(37 99 235) !important; /* blue-600 */
|
||||
color: rgb(37 99 235) !important;
|
||||
/* blue-600 */
|
||||
}
|
||||
|
||||
/* Dark mode toast styling */
|
||||
@@ -252,4 +265,4 @@
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
+117
-30
@@ -1,5 +1,5 @@
|
||||
import { GetDefaults } 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";
|
||||
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" | "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 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 {
|
||||
@@ -21,8 +21,8 @@ export interface Settings {
|
||||
embedMaxQualityCover: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS";
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
amazonQuality: "HI_RES";
|
||||
qobuzQuality: "6" | "7";
|
||||
amazonQuality: "original";
|
||||
}
|
||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||
label: string;
|
||||
@@ -95,30 +95,31 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
operatingSystem: detectOS(),
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "HI_RES"
|
||||
amazonQuality: "original"
|
||||
};
|
||||
export const FONT_OPTIONS: {
|
||||
value: FontFamily;
|
||||
label: string;
|
||||
fontFamily: string;
|
||||
}[] = [
|
||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", 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' },
|
||||
];
|
||||
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
|
||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", 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 {
|
||||
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
||||
if (font) {
|
||||
@@ -137,7 +138,8 @@ async function fetchDefaultPath(): Promise<string> {
|
||||
}
|
||||
}
|
||||
const SETTINGS_KEY = "spotiflac-settings";
|
||||
export function getSettings(): Settings {
|
||||
let cachedSettings: Settings | null = null;
|
||||
function getSettingsFromLocalStorage(): Settings {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
if (stored) {
|
||||
@@ -188,17 +190,99 @@ export function getSettings(): Settings {
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (parsed.qobuzQuality === "27") {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (!('amazonQuality' in parsed)) {
|
||||
parsed.amazonQuality = "HI_RES";
|
||||
parsed.amazonQuality = "original";
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
console.error("Failed to load settings from local storage:", error);
|
||||
}
|
||||
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 {
|
||||
artist?: string;
|
||||
album?: string;
|
||||
@@ -224,30 +308,33 @@ export function parseTemplate(template: string, data: TemplateData): string {
|
||||
return result;
|
||||
}
|
||||
export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||
const settings = getSettings();
|
||||
const settings = await loadSettings();
|
||||
if (!settings.downloadPath) {
|
||||
settings.downloadPath = await fetchDefaultPath();
|
||||
await saveSettings(settings);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
export function saveSettings(settings: Settings): void {
|
||||
export async function saveSettings(settings: Settings): Promise<void> {
|
||||
try {
|
||||
cachedSettings = settings;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
await SaveToBackend(settings as any);
|
||||
}
|
||||
catch (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 updated = { ...current, ...partial };
|
||||
saveSettings(updated);
|
||||
await saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||
const defaultPath = await fetchDefaultPath();
|
||||
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||
saveSettings(defaultSettings);
|
||||
await saveSettings(defaultSettings);
|
||||
return defaultSettings;
|
||||
}
|
||||
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface AlbumResponse {
|
||||
track_list: TrackMetadata[];
|
||||
}
|
||||
export interface PlaylistInfo {
|
||||
name: string;
|
||||
tracks: {
|
||||
total: number;
|
||||
};
|
||||
|
||||
+10
-12
@@ -1,14 +1,12 @@
|
||||
import path from "path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// https://vite.dev/config/
|
||||
import path from "path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
+2
-3
@@ -12,9 +12,8 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "7.0.2",
|
||||
"copyright": "© 2026 afkarxyz",
|
||||
"comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required."
|
||||
"productVersion": "7.0.5",
|
||||
"copyright": "© 2026 afkarxyz"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
"assetdir": "./frontend/dist",
|
||||
|
||||
Reference in New Issue
Block a user