Compare commits

..

27 Commits

Author SHA1 Message Date
afkarxyz 4f135f1153 v7.0.5 2026-01-14 08:23:50 +07:00
afkarxyz 4ee252f438 v7.0.5 2026-01-14 07:36:14 +07:00
afkarxyz 2fc08de757 v7.0.5 2026-01-14 06:28:51 +07:00
afkarxyz 6e3ca48d3f v7.0.5 2026-01-13 23:28:06 +07:00
afkarxyz 46a7777698 v7.0.5 2026-01-13 22:45:08 +07:00
afkarxyz 0f2174bf80 v7.0.4 2026-01-11 23:18:18 +07:00
afkarxyz 36fb34dc63 v7.0.3 2026-01-11 22:41:29 +07:00
afkarxyz 7f859db173 v7.0.2 2026-01-11 17:34:12 +07:00
afkarxyz 6e66105481 v7.0.1 2026-01-11 10:42:48 +07:00
afkarxyz 85b542983e v7.0.1 2026-01-11 10:39:37 +07:00
afkarxyz ecc6fd961a v7.0.1 2026-01-11 10:18:51 +07:00
afkarxyz 9260adc2d2 v7.0.1 2026-01-11 08:39:14 +07:00
afkarxyz cb6dfc1638 . 2026-01-11 07:52:33 +07:00
afkarxyz 5dacd70287 . 2026-01-10 12:36:51 +07:00
afkarxyz b163355c50 . 2026-01-09 22:54:57 +07:00
afkarxyz 58495dd9fd . 2026-01-09 22:50:24 +07:00
afkarxyz 1eb8a5ac0c . 2026-01-09 17:39:42 +07:00
afkarxyz 452cd9e118 . 2026-01-09 17:37:16 +07:00
Harley Welsh 1345ac25f4 fix: resolve nested download paths for covers and lyrics (#219)
This commit fixes an issue where cover art and lyrics files were being saved in deeply nested directories (e.g. Album/Artist/Album/file) instead of the correct Artist/Album/file path. It adds an isAlbum flag to the frontend hooks to prevent redundant path construction when downloading in an album context.

Co-authored-by: Harley <git@haileywelsh.me>
2026-01-08 12:34:29 +07:00
SjxSubham ae8b610462 Trim whitespace in sanitizePath function (#215)
Add whitespace trimming to sanitizePath function.
2026-01-08 12:34:08 +07:00
Rin 14297171be Security: Enforce strict validation for FFmpeg binary paths (#214) 2026-01-08 12:33:50 +07:00
afkarxyz 6f6c7563a0 .maintenance 2026-01-08 09:07:18 +07:00
afkarxyz a52c2bb658 .maintenance 2026-01-08 08:23:45 +07:00
afkarxyz 2ce400a5f7 Update README.md 2026-01-01 21:43:46 +07:00
Zarz Eleutherius b8fd2d1762 Update project name and description in README (#217) 2026-01-01 21:24:15 +07:00
afkarxyz d2af0d11df .license 2026-01-01 05:48:29 +07:00
afkarxyz 57640d85d2 v7.0 2025-12-24 09:16:25 +07:00
108 changed files with 13177 additions and 13539 deletions
+1 -1
View File
@@ -61,4 +61,4 @@ test
# Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md
build.txt
push.bat
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 afkarxyz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+31 -3
View File
@@ -1,5 +1,7 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
<!-- ![Maintenance](https://maintenance.afkarxyz.fun?v=3) -->
![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<div align="center">
@@ -10,18 +12,44 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-Any-FCC624?style=for-the-badge&logo=linux&logoColor=white)
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
## Screenshot
![Image](https://github.com/user-attachments/assets/afe01529-bcf0-4486-8792-62af26adafee)
![Image](https://github.com/user-attachments/assets/4bc2d45a-8afc-4c91-9d57-afdbd2b9c225)
## Other project
## Other projects
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
### [SpotubeDL](https://spotubedl.com)
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz)
> Every coffee helps me keep going
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
2. Reading and adhering to the Terms of Service of the respective platforms.
3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
+417 -149
View File
File diff suppressed because it is too large Load Diff
+231 -56
View File
@@ -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{
@@ -63,16 +82,14 @@ func (a *AmazonDownloader) getRandomUserAgent() string {
}
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
// Reset counter every minute
now := time.Now()
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
@@ -82,10 +99,9 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
}
// Add delay between requests (6 seconds = 10 requests per minute)
if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second // 7 seconds to be safe
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
@@ -93,7 +109,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -109,7 +124,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
fmt.Println("Getting Amazon URL...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -118,11 +132,10 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
// Update rate limit tracking
a.lastAPICallTime = time.Now()
a.apiCallCount++
if resp.StatusCode == 429 { // Too Many Requests
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
@@ -142,7 +155,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
defer resp.Body.Close()
// Read body first to handle encoding issues and provide better error messages
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
@@ -154,7 +166,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
var songLinkResp SongLinkResponse
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -169,7 +181,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if strings.Contains(amazonURL, "trackAsin=") {
parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 {
@@ -183,17 +194,201 @@ 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)
// Decode base64 service URL
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
@@ -234,7 +429,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
downloadID := submitResp.ID
fmt.Printf("Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("Waiting for download to complete...")
@@ -276,7 +470,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
if status.Status == "done" {
fmt.Println("\nDownload ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
@@ -289,7 +482,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
// Download file
downloadReq, err := http.NewRequest("GET", fileURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create download request: %w", err)
@@ -310,7 +502,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
break
}
// Generate filename
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
for _, char := range `<>:"/\|?*` {
fileName = strings.ReplaceAll(fileName, string(char), "")
@@ -319,7 +510,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
filePath := filepath.Join(outputDir, fileName)
// Save file
out, err := os.Create(filePath)
if err != nil {
lastError = fmt.Errorf("failed to create file: %w", err)
@@ -328,7 +518,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, fileResp.Body)
if err != nil {
@@ -336,7 +526,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete!")
return filePath, nil
@@ -349,7 +538,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
lastError = fmt.Errorf("processing failed: %s", errorMsg)
break
} else {
// Still processing
friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" {
friendlyStatus = status.Status
@@ -372,15 +561,14 @@ 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, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
// Create output directory if needed
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 {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename)
@@ -393,29 +581,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
// Download from service
filePath, err := a.DownloadFromService(amazonURL, outputDir)
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
if err != nil {
return "", err
}
// Rename file based on Spotify metadata
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Extract year from release date
year := ""
if len(spotifyReleaseDate) >= 4 {
year = spotifyReleaseDate[:4]
}
// Build filename based on format settings
var newFilename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
@@ -424,34 +607,31 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
// Handle disc number
if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
} else {
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default: // "title-artist"
default:
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
}
@@ -460,7 +640,6 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = newFilename + ".flac"
newFilePath := filepath.Join(outputDir, newFilename)
// Rename file
if err := os.Rename(filePath, newFilePath); err != nil {
fmt.Printf("Warning: Failed to rename file: %v\n", err)
} else {
@@ -469,11 +648,10 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
}
}
// Embed Spotify metadata (replace Amazon's embedded metadata)
fmt.Println("Embedding Spotify metadata...")
coverPath := ""
// Download Spotify cover (with max resolution if enabled)
if spotifyCoverURL != "" {
coverPath = filePath + ".cover.jpg"
coverClient := NewCoverClient()
@@ -486,27 +664,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
}
}
// Determine track number to embed
// Use Spotify track number (album track number) if available, otherwise use position
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = position // Fallback to playlist position
}
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1 // Default to track 1 for single track downloads without track number
trackNumberToEmbed = 1
}
// Build metadata from Spotify
metadata := Metadata{
Title: spotifyTrackName,
Artist: spotifyArtistName,
Album: spotifyAlbumName,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
ISRC: spotifyISRC, // Use ISRC from Spotify
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
@@ -521,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, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
// Get Amazon URL from Spotify track ID
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, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover)
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
}
+5 -30
View File
@@ -9,7 +9,6 @@ import (
mewflac "github.com/mewkiz/flac"
)
// AnalysisResult contains the audio analysis data
type AnalysisResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
@@ -25,19 +24,16 @@ type AnalysisResult struct {
Spectrum *SpectrumData `json:"spectrum,omitempty"`
}
// AnalyzeTrack performs audio analysis on a FLAC file
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
// Get file size
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
// Parse FLAC file
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
@@ -48,68 +44,55 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
FileSize: fileInfo.Size(),
}
// Extract basic audio properties from STREAMINFO block
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
// Read STREAMINFO data
data := streamInfo.Data
if len(data) >= 18 {
// Sample rate (bits 10-29 of bytes 10-13)
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
// Channels (bits 30-32 of byte 12)
result.Channels = ((data[12] >> 1) & 0x07) + 1
// Bits per sample (bits 33-37 of bytes 12-13)
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
// Total samples (bits 38-73 of bytes 13-17)
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
// Calculate duration
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
// Read min/max frame size and block size for additional analysis
// Min block size (bytes 0-1)
// Max block size (bytes 2-3)
// These can give us hints about encoding quality
}
}
}
// Analyze spectrum and calculate real audio metrics
spectrum, err := AnalyzeSpectrum(filepath)
if err != nil {
// Log error but continue
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
} else {
result.Spectrum = spectrum
// Calculate dynamic range, peak, and RMS from decoded samples
calculateRealAudioMetrics(result, filepath)
}
// Set bit depth
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
// Decode FLAC to get actual samples
samples, err := decodeFLACForMetrics(filepath)
if err != nil {
return
}
// Calculate peak amplitude
var peak float64
var sumSquares float64
@@ -124,20 +107,16 @@ func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
sumSquares += sample * sample
}
// Convert peak to dB (reference: 1.0 = 0 dBFS)
peakDB := 20.0 * math.Log10(peak)
result.PeakAmplitude = peakDB
// Calculate RMS (Root Mean Square)
rms := math.Sqrt(sumSquares / float64(len(samples)))
rmsDB := 20.0 * math.Log10(rms)
result.RMSLevel = rmsDB
// Dynamic range is the difference between peak and RMS
result.DynamicRange = peakDB - rmsDB
}
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
func decodeFLACForMetrics(filepath string) ([]float64, error) {
stream, err := mewflac.ParseFile(filepath)
if err != nil {
@@ -145,24 +124,20 @@ func decodeFLACForMetrics(filepath string) ([]float64, error) {
}
defer stream.Close()
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
maxSamples := 10000000
samples := make([]float64, 0, maxSamples)
// Read all audio frames
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
// Get samples from first channel (mono or left channel)
var channelSamples []int32
if len(frame.Subframes) > 0 {
channelSamples = frame.Subframes[0].Samples
}
// Normalize samples to -1.0 to 1.0 range
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
for _, sample := range channelSamples {
if len(samples) >= maxSamples {
+2 -3
View File
@@ -6,13 +6,12 @@ import (
)
func GetDefaultMusicPath() string {
// Get user's home directory
homeDir, err := os.UserHomeDir()
if err != nil {
// Fallback to Public Music if can't get home dir
return "C:\\Users\\Public\\Music"
}
// Return path to user's Music folder
return filepath.Join(homeDir, "Music")
}
+315 -48
View File
@@ -12,12 +12,11 @@ import (
)
const (
// Spotify image size codes
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySizeMax = "ab67616d000082c1" // Max resolution
spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1"
)
// CoverDownloadRequest represents a request to download cover art
type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
@@ -32,7 +31,6 @@ type CoverDownloadRequest struct {
DiscNumber int `json:"disc_number"`
}
// CoverDownloadResponse represents the response from cover download
type CoverDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
@@ -41,26 +39,36 @@ type CoverDownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
}
// CoverClient handles cover art downloading
type HeaderDownloadRequest struct {
HeaderURL string `json:"header_url"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
}
type HeaderDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
type CoverClient struct {
httpClient *http.Client
}
// NewCoverClient creates a new cover client
func NewCoverClient() *CoverClient {
return &CoverClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// buildCoverFilename builds the cover filename based on settings (same as track filename)
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
@@ -68,7 +76,6 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
@@ -77,72 +84,67 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title-artist":
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
filename = fmt.Sprintf("%02d - %s", position, filename)
}
}
return filename + ".jpg"
return filename + ".cover.jpg"
}
// getMaxResolutionURL converts a Spotify cover URL to max resolution
// Falls back to original URL if max resolution is not available
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
// Try to convert to max resolution
if strings.Contains(coverURL, spotifySize640) {
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
// Check if max resolution URL is available
resp, err := c.httpClient.Head(maxURL)
if err == nil && resp.StatusCode == http.StatusOK {
return maxURL
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
}
}
// Return original URL as fallback
return coverURL
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
}
// DownloadCoverToPath downloads cover art from URL to a specific path
// If embedMaxQualityCover is true, it will try to get max resolution
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
if coverURL == "" {
return fmt.Errorf("cover URL is required")
}
// Use max quality URL if setting is enabled
downloadURL := coverURL
downloadURL := convertSmallToMedium(coverURL)
if embedMaxQualityCover {
downloadURL = c.getMaxResolutionURL(coverURL)
downloadURL = c.getMaxResolutionURL(downloadURL)
}
// Download cover image
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("failed to download cover: %v", err)
@@ -153,14 +155,12 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
}
// Create file
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %v", err)
}
defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write cover file: %v", err)
@@ -169,7 +169,6 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
return nil
}
// DownloadCover downloads cover art for a single track
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
if req.CoverURL == "" {
return &CoverDownloadResponse{
@@ -178,7 +177,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, fmt.Errorf("cover URL is required")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
@@ -193,15 +191,13 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
filenameFormat = "title-artist"
}
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &CoverDownloadResponse{
Success: true,
@@ -211,10 +207,8 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, nil
}
// Try to get max resolution URL, fallback to original
downloadURL := c.getMaxResolutionURL(req.CoverURL)
// Download cover image
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return &CoverDownloadResponse{
@@ -231,7 +225,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Create file
file, err := os.Create(filePath)
if err != nil {
return &CoverDownloadResponse{
@@ -241,7 +234,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}
defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body)
if err != nil {
return &CoverDownloadResponse{
@@ -256,3 +248,278 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
File: filePath,
}, nil
}
func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) {
if req.HeaderURL == "" {
return &HeaderDownloadResponse{
Success: false,
Error: "Header URL is required",
}, fmt.Errorf("header URL is required")
}
if req.ArtistName == "" {
return &HeaderDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + "_Header.jpg"
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &HeaderDownloadResponse{
Success: true,
Message: "Header file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.HeaderURL)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download header: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write header file: %v", err),
}, err
}
return &HeaderDownloadResponse{
Success: true,
Message: "Header downloaded successfully",
File: filePath,
}, nil
}
type GalleryImageDownloadRequest struct {
ImageURL string `json:"image_url"`
ArtistName string `json:"artist_name"`
ImageIndex int `json:"image_index"`
OutputDir string `json:"output_dir"`
}
type GalleryImageDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) {
if req.ImageURL == "" {
return &GalleryImageDownloadResponse{
Success: false,
Error: "Image URL is required",
}, fmt.Errorf("image URL is required")
}
if req.ArtistName == "" {
return &GalleryImageDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1)
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &GalleryImageDownloadResponse{
Success: true,
Message: "Gallery image file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.ImageURL)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download gallery image: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write gallery image file: %v", err),
}, err
}
return &GalleryImageDownloadResponse{
Success: true,
Message: "Gallery image downloaded successfully",
File: filePath,
}, nil
}
type AvatarDownloadRequest struct {
AvatarURL string `json:"avatar_url"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
}
type AvatarDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) {
if req.AvatarURL == "" {
return &AvatarDownloadResponse{
Success: false,
Error: "Avatar URL is required",
}, fmt.Errorf("avatar URL is required")
}
if req.ArtistName == "" {
return &AvatarDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg"
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &AvatarDownloadResponse{
Success: true,
Message: "Avatar file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.AvatarURL)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download avatar: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write avatar file: %v", err),
}, err
}
return &AvatarDownloadResponse{
Success: true,
Message: "Avatar downloaded successfully",
File: filePath,
}, nil
}
+142 -77
View File
@@ -13,11 +13,11 @@ import (
"runtime"
"strings"
"sync"
"time"
"github.com/ulikunitz/xz"
)
// decodeBase64 decodes a base64 encoded string
func decodeBase64(encoded string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
@@ -26,6 +26,45 @@ func decodeBase64(encoded string) (string, error) {
return string(decoded), nil
}
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
if !filepath.IsAbs(cleanedPath) {
return fmt.Errorf("path must be absolute: %s", path)
}
info, err := os.Stat(cleanedPath)
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory: %s", path)
}
if runtime.GOOS != "windows" {
if info.Mode()&0111 == 0 {
return fmt.Errorf("file is not executable: %s", path)
}
}
base := filepath.Base(cleanedPath)
validNames := map[string]bool{
"ffmpeg": true,
"ffmpeg.exe": true,
"ffprobe": true,
"ffprobe.exe": true,
}
if !validNames[base] {
return fmt.Errorf("invalid executable name: %s", base)
}
return nil
}
const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
@@ -33,7 +72,6 @@ const (
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
// GetFFmpegDir returns the directory where ffmpeg should be stored
func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -42,7 +80,6 @@ func GetFFmpegDir() (string, error) {
return filepath.Join(homeDir, ".spotiflac"), nil
}
// GetFFmpegPath returns the full path to the ffmpeg executable
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
@@ -57,7 +94,6 @@ func GetFFmpegPath() (string, error) {
return filepath.Join(ffmpegDir, ffmpegName), nil
}
// GetFFprobePath returns the full path to the ffprobe executable in app directory
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
@@ -77,62 +113,61 @@ func GetFFprobePath() (string, error) {
return "", fmt.Errorf("ffprobe not found in app directory")
}
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
// Verify it's executable
if err := ValidateExecutable(ffprobePath); err != nil {
return false, nil
}
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return false, err
}
_, err = os.Stat(ffmpegPath)
if os.IsNotExist(err) {
if err := ValidateExecutable(ffmpegPath); err != nil {
return false, nil
}
if err != nil {
return false, err
}
// Verify it's executable
cmd := exec.Command(ffmpegPath, "-version")
// Hide console window on Windows
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0)
SetDownloadSpeed(0)
SetDownloading(true)
defer SetDownloading(false)
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return err
}
// Create directory if it doesn't exist
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
// For macOS, download ffmpeg and ffprobe separately (only if not already installed)
if runtime.GOOS == "darwin" {
ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled {
// Download both
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
@@ -145,14 +180,14 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
} else if !ffmpegInstalled {
// Only download ffmpeg
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
} else if !ffprobeInstalled {
// Only download ffprobe
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
@@ -162,7 +197,6 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil
}
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
var encodedURL string
switch runtime.GOOS {
case "windows":
@@ -173,7 +207,6 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
// Decode URL
url, err := decodeBase64(encodedURL)
if err != nil {
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
@@ -188,9 +221,8 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil
}
// downloadAndExtract downloads a file and extracts it
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
// Create temporary file for download
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
@@ -198,7 +230,6 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Download the file
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
@@ -211,8 +242,16 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
totalSize := resp.ContentLength
var downloaded int64
lastTime := time.Now()
var lastBytes int64
if totalSize > 0 {
totalSizeMB := float64(totalSize) / (1024 * 1024)
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
} else {
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
}
// Create a progress reader
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
@@ -222,12 +261,46 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
return fmt.Errorf("failed to write to temp file: %w", writeErr)
}
downloaded += int64(n)
mbDownloaded := float64(downloaded) / (1024 * 1024)
now := time.Now()
timeDiff := now.Sub(lastTime).Seconds()
var speedMBps float64
if timeDiff > 0.1 {
bytesDiff := float64(downloaded - lastBytes)
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
lastTime = now
lastBytes = downloaded
}
SetDownloadProgress(mbDownloaded)
if speedMBps > 0 {
SetDownloadSpeed(speedMBps)
}
if totalSize > 0 && progressCallback != nil {
// Scale progress between progressStart and progressEnd
rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress)
}
if totalSize > 0 {
percent := float64(downloaded) * 100 / float64(totalSize)
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
mbDownloaded, float64(totalSize)/(1024*1024), percent)
}
} else {
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
}
}
}
if err == io.EOF {
break
@@ -239,16 +312,20 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
tmpFile.Close()
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
if totalSize > 0 {
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n",
float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024))
} else {
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024))
}
fmt.Printf("[FFmpeg] Extracting...\n")
// Extract the archive based on file type
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
return extractTarXz(tmpFile.Name(), destDir)
}
return extractZip(tmpFile.Name(), destDir)
}
// extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
@@ -280,7 +357,7 @@ func extractZip(zipPath, destDir string) error {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
// Skip ffplay and other files
continue
}
@@ -308,7 +385,6 @@ func extractZip(zipPath, destDir string) error {
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
@@ -323,7 +399,6 @@ func extractZip(zipPath, destDir string) error {
return nil
}
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
func extractTarXz(tarXzPath, destDir string) error {
file, err := os.Open(tarXzPath)
if err != nil {
@@ -366,7 +441,7 @@ func extractTarXz(tarXzPath, destDir string) error {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
// Skip ffplay and other files
continue
}
@@ -387,7 +462,6 @@ func extractTarXz(tarXzPath, destDir string) error {
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
@@ -402,15 +476,13 @@ func extractTarXz(tarXzPath, destDir string) error {
return nil
}
// ConvertAudioRequest represents a request to convert audio files
type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"` // mp3, m4a
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" (ignored for ALAC)
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac"
OutputFormat string `json:"output_format"`
Bitrate string `json:"bitrate"`
Codec string `json:"codec"`
}
// ConvertAudioResult represents the result of a single file conversion
type ConvertAudioResult struct {
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
@@ -418,13 +490,16 @@ type ConvertAudioResult struct {
Error string `json:"error,omitempty"`
}
// ConvertAudio converts audio files using ffmpeg while preserving metadata
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
}
installed, err := IsFFmpegInstalled()
if err != nil || !installed {
return nil, fmt.Errorf("ffmpeg is not installed")
@@ -434,7 +509,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
var wg sync.WaitGroup
var mu sync.Mutex
// Convert files in parallel
for i, inputFile := range req.InputFiles {
wg.Add(1)
go func(idx int, inputFile string) {
@@ -444,16 +518,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
InputFile: inputFile,
}
// Get input file info
inputExt := strings.ToLower(filepath.Ext(inputFile))
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
inputDir := filepath.Dir(inputFile)
// Determine output directory: same as input file location + subfolder (MP3 or M4A)
outputFormatUpper := strings.ToUpper(req.OutputFormat)
outputDir := filepath.Join(inputDir, outputFormatUpper)
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0755); err != nil {
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
result.Success = false
@@ -463,11 +534,9 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
return
}
// Determine output path
outputExt := "." + strings.ToLower(req.OutputFormat)
outputFile := filepath.Join(outputDir, baseName+outputExt)
// Skip if same format
if inputExt == outputExt {
result.Error = "Input and output formats are the same"
result.Success = false
@@ -479,9 +548,14 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
result.OutputFile = outputFile
// Extract cover art and lyrics from input file before conversion
var coverArtPath string
var lyrics string
var inputMetadata Metadata
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
}
coverArtPath, _ = ExtractCoverArt(inputFile)
lyrics, err = ExtractLyrics(inputFile)
@@ -493,49 +567,42 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
}
// Build ffmpeg command
inputMetadata.Lyrics = lyrics
args := []string{
"-i", inputFile,
"-y", // Overwrite output
"-y",
}
// Add codec and bitrate based on output format
switch req.OutputFormat {
case "mp3":
args = append(args,
"-codec:a", "libmp3lame",
"-b:a", req.Bitrate,
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
"-id3v2_version", "3", // Use ID3v2.3 for better compatibility
"-map", "0:a",
"-id3v2_version", "3",
)
// Map video stream if exists (for cover art)
args = append(args, "-map", "0:v?", "-c:v", "copy")
case "m4a":
// Determine codec: ALAC (lossless) or AAC (lossy)
codec := req.Codec
if codec == "" {
codec = "aac" // Default to AAC for backward compatibility
codec = "aac"
}
if codec == "alac" {
// ALAC - Apple Lossless (no bitrate needed)
args = append(args,
"-codec:a", "alac",
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
"-map", "0:a",
)
} else {
// AAC - lossy with bitrate
args = append(args,
"-codec:a", "aac",
"-b:a", req.Bitrate,
"-map", "0:a", // Map audio stream
"-map_metadata", "0", // Copy all metadata
"-map", "0:a",
)
}
// Map video stream for cover art in M4A
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
}
args = append(args, outputFile)
@@ -543,7 +610,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
cmd := exec.Command(ffmpegPath, args...)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -552,21 +619,17 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
mu.Lock()
results[idx] = result
mu.Unlock()
// Clean up temp cover art file if exists
if coverArtPath != "" {
os.Remove(coverArtPath)
}
return
}
// Embed cover art and lyrics after conversion if they were extracted
if coverArtPath != "" {
if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err)
if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Cover art embedded successfully\n")
}
os.Remove(coverArtPath) // Clean up temp file
fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
}
if lyrics != "" {
@@ -577,6 +640,10 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
}
}
if coverArtPath != "" {
os.Remove(coverArtPath)
}
result.Success = true
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
@@ -590,7 +657,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
return results, nil
}
// GetAudioInfo returns information about an audio file
type AudioFileInfo struct {
Path string `json:"path"`
Filename string `json:"filename"`
@@ -598,7 +664,6 @@ type AudioFileInfo struct {
Size int64 `json:"size"`
}
// GetAudioFileInfo gets information about an audio file
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
info, err := os.Stat(filePath)
if err != nil {
+1 -3
View File
@@ -7,8 +7,6 @@ import (
"os/exec"
)
// setHideWindow is a no-op on non-Windows platforms
func setHideWindow(cmd *exec.Cmd) {
// No-op on Unix-like systems
}
}
-2
View File
@@ -8,10 +8,8 @@ import (
"syscall"
)
// setHideWindow sets HideWindow attribute for Windows processes
func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}
-3
View File
@@ -6,7 +6,6 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// SelectMultipleFiles opens a file dialog to select multiple audio files
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Audio Files",
@@ -39,7 +38,6 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
return files, nil
}
// SelectOutputDirectory opens a directory dialog to select output folder
func SelectOutputDirectory(ctx context.Context) (string, error) {
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Output Directory",
@@ -49,4 +47,3 @@ func SelectOutputDirectory(ctx context.Context) (string, error) {
}
return dir, nil
}
+7 -38
View File
@@ -14,7 +14,6 @@ import (
"github.com/go-flac/go-flac"
)
// FileInfo represents information about a file or folder
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
@@ -23,7 +22,6 @@ type FileInfo struct {
Children []FileInfo `json:"children,omitempty"`
}
// AudioMetadata represents metadata read from an audio file
type AudioMetadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
@@ -34,7 +32,6 @@ type AudioMetadata struct {
Year string `json:"year"`
}
// RenamePreview represents a preview of file rename operation
type RenamePreview struct {
OldPath string `json:"old_path"`
OldName string `json:"old_name"`
@@ -44,7 +41,6 @@ type RenamePreview struct {
Metadata AudioMetadata `json:"metadata"`
}
// RenameResult represents the result of a rename operation
type RenameResult struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
@@ -52,7 +48,6 @@ type RenameResult struct {
Error string `json:"error,omitempty"`
}
// ListDirectory lists files and folders in a directory
func ListDirectory(dirPath string) ([]FileInfo, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
@@ -73,7 +68,6 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
Size: info.Size(),
}
// If it's a directory, recursively list its contents
if entry.IsDir() {
children, err := ListDirectory(fileInfo.Path)
if err == nil {
@@ -87,13 +81,12 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
return result, nil
}
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
var result []FileInfo
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files with errors
return nil
}
if info.IsDir() {
@@ -120,7 +113,6 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
return result, nil
}
// ReadAudioMetadata reads metadata from an audio file
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
@@ -140,7 +132,6 @@ func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
}
}
// readFlacMetadata reads metadata from a FLAC file
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -192,7 +183,6 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) {
return metadata, nil
}
// readMp3Metadata reads metadata from an MP3 file
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
@@ -207,14 +197,12 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
Year: tag.Year(),
}
// Get Album Artist (TPE2)
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
metadata.AlbumArtist = textFrame.Text
}
}
// Get Track Number
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
trackStr := strings.Split(textFrame.Text, "/")[0]
@@ -224,7 +212,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
}
}
// Get Disc Number
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
discStr := strings.Split(textFrame.Text, "/")[0]
@@ -237,14 +224,16 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
return metadata, nil
}
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return nil, err
}
// Use ffprobe to get metadata in JSON format (both format and stream tags)
if err := ValidateExecutable(ffprobePath); err != nil {
return nil, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
@@ -253,7 +242,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
filePath,
)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.Output()
@@ -261,7 +249,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
return nil, err
}
// Parse JSON output
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
@@ -277,22 +264,18 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
metadata := &AudioMetadata{}
// Merge tags from format and streams (format tags take priority)
allTags := make(map[string]string)
// First add stream tags
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
// Then add format tags (overwrite stream tags)
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
// Parse tags
for key, value := range allTags {
switch key {
case "title":
@@ -304,7 +287,7 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "track":
// Format might be "4" or "4/12"
trackStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
@@ -324,7 +307,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
return metadata, nil
}
// readM4aMetadata reads metadata from an M4A file using ffprobe
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
metadata, err := readMetadataWithFFprobe(filePath)
if err != nil {
@@ -333,7 +315,6 @@ func readM4aMetadata(filePath string) (*AudioMetadata, error) {
return metadata, nil
}
// GenerateFilename generates a new filename based on metadata and format template
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
if metadata == nil {
return ""
@@ -341,38 +322,32 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
result := format
// Extract year (first 4 characters only)
year := metadata.Year
if len(year) >= 4 {
year = year[:4]
}
// Replace placeholders
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
// Track number with padding
if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
} else {
result = strings.ReplaceAll(result, "{track}", "")
}
// Disc number
if metadata.DiscNumber > 0 {
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
} else {
result = strings.ReplaceAll(result, "{disc}", "")
}
// Clean up multiple spaces and trim
result = strings.TrimSpace(result)
result = strings.Join(strings.Fields(result), " ")
// Remove leading/trailing separators
result = strings.Trim(result, " -._")
if result == "" {
@@ -382,9 +357,8 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
return result + ext
}
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
func sanitizeFilenameForRename(name string) string {
// Remove characters that are invalid in filenames
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
result := name
for _, char := range invalid {
@@ -393,7 +367,6 @@ func sanitizeFilenameForRename(name string) string {
return strings.TrimSpace(result)
}
// PreviewRename generates a preview of rename operations
func PreviewRename(files []string, format string) []RenamePreview {
var previews []RenamePreview
@@ -430,7 +403,6 @@ func PreviewRename(files []string, format string) []RenamePreview {
return previews
}
// GetFileSizes returns file sizes for a list of file paths
func GetFileSizes(files []string) map[string]int64 {
result := make(map[string]int64)
for _, filePath := range files {
@@ -442,7 +414,6 @@ func GetFileSizes(files []string) map[string]int64 {
return result
}
// RenameFiles renames files based on their metadata
func RenameFiles(files []string, format string) []RenameResult {
var results []RenameResult
@@ -472,7 +443,6 @@ func RenameFiles(files []string, format string) []RenameResult {
newPath := filepath.Join(filepath.Dir(filePath), newName)
result.NewPath = newPath
// Check if new path already exists (and is different from old path)
if newPath != filePath {
if _, err := os.Stat(newPath); err == nil {
result.Error = "File already exists"
@@ -482,7 +452,6 @@ func RenameFiles(files []string, format string) []RenameResult {
}
}
// Rename the file
if err := os.Rename(filePath, newPath); err != nil {
result.Error = err.Error()
result.Success = false
+13 -48
View File
@@ -9,15 +9,13 @@ import (
"unicode/utf8"
)
// BuildExpectedFilename builds the expected filename based on track metadata and settings
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
@@ -25,7 +23,6 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
@@ -34,34 +31,31 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators like ". " or " - " or ". "
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
@@ -70,109 +64,81 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
return filename + ".flac"
}
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(name string) string {
// Replace forward slash with space (more natural than underscore)
sanitized := strings.ReplaceAll(name, "/", " ")
// Remove other invalid filesystem characters (replace with space)
re := regexp.MustCompile(`[<>:"\\|?*]`)
sanitized = re.ReplaceAllString(sanitized, " ")
// Remove control characters (0x00-0x1F, 0x7F)
var result strings.Builder
for _, r := range sanitized {
// Keep printable characters and valid Unicode characters
// Remove control characters, but keep spaces, tabs, newlines for now
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
// Remove emoji and other symbols that might cause issues
// Keep letters, numbers, and common punctuation
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
// Remove emoji ranges (most emoji are in these ranges)
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
continue
}
result.WriteRune(r)
}
sanitized = result.String()
sanitized = strings.TrimSpace(sanitized)
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
sanitized = strings.Trim(sanitized, ". ")
// Normalize consecutive spaces to single space
re = regexp.MustCompile(`\s+`)
sanitized = re.ReplaceAllString(sanitized, " ")
// Normalize consecutive underscores to single underscore
re = regexp.MustCompile(`_+`)
sanitized = re.ReplaceAllString(sanitized, "_")
// Remove leading/trailing underscores and spaces
sanitized = strings.Trim(sanitized, "_ ")
if sanitized == "" {
return "Unknown"
}
// Ensure the result is valid UTF-8
if !utf8.ValidString(sanitized) {
// If invalid UTF-8, try to fix it
sanitized = strings.ToValidUTF8(sanitized, "_")
}
return sanitized
}
// NormalizePath only normalizes path separators without modifying folder names
// Use this for user-provided paths that already exist on the filesystem
func NormalizePath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
}
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
// Use this only for NEW folders being created (artist names, album names, etc.)
func SanitizeFolderPath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
// Detect separator
sep := string(filepath.Separator)
// Split path into components
parts := strings.Split(normalizedPath, sep)
sanitizedParts := make([]string, 0, len(parts))
for i, part := range parts {
// Keep drive letter intact on Windows (e.g., "C:")
if i == 0 && len(part) == 2 && part[1] == ':' {
sanitizedParts = append(sanitizedParts, part)
continue
}
// Keep empty first part for absolute paths on Unix (e.g., "/Users/...")
if i == 0 && part == "" {
sanitizedParts = append(sanitizedParts, part)
continue
}
// Sanitize each folder name (but don't replace / or \ since we already normalized)
sanitized := sanitizeFolderName(part)
if sanitized != "" {
sanitizedParts = append(sanitizedParts, sanitized)
@@ -182,8 +148,7 @@ func SanitizeFolderPath(folderPath string) string {
return strings.Join(sanitizedParts, sep)
}
// sanitizeFolderName removes invalid characters from a single folder name
func sanitizeFolderName(name string) string {
// Use the same sanitization as filename
return sanitizeFilename(name)
}
+2 -4
View File
@@ -14,7 +14,7 @@ func OpenFolderInExplorer(path string) error {
switch runtime.GOOS {
case "windows":
cmd = exec.Command("explorer", path)
case "darwin": // macOS
case "darwin":
cmd = exec.Command("open", path)
case "linux":
cmd = exec.Command("xdg-open", path)
@@ -26,7 +26,7 @@ func OpenFolderInExplorer(path string) error {
}
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
// If defaultPath is empty, use default music path
if defaultPath == "" {
defaultPath = GetDefaultMusicPath()
}
@@ -41,7 +41,6 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
return "", err
}
// If user cancelled, selectedPath will be empty
if selectedPath == "" {
return "", nil
}
@@ -69,7 +68,6 @@ func SelectFileDialog(ctx context.Context) (string, error) {
return "", err
}
// If user cancelled, selectedFile will be empty
if selectedFile == "" {
return "", nil
}
+94 -55
View File
@@ -14,7 +14,6 @@ import (
"time"
)
// LRCLibResponse represents the LRCLIB API response
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -27,21 +26,18 @@ type LRCLibResponse struct {
SyncedLyrics string `json:"syncedLyrics"`
}
// LyricsLine represents a single line of lyrics
type LyricsLine struct {
StartTimeMs string `json:"startTimeMs"`
Words string `json:"words"`
EndTimeMs string `json:"endTimeMs"`
}
// LyricsResponse represents the API response
type LyricsResponse struct {
Error bool `json:"error"`
SyncType string `json:"syncType"`
Lines []LyricsLine `json:"lines"`
}
// LyricsDownloadRequest represents a request to download lyrics
type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
@@ -57,7 +53,6 @@ type LyricsDownloadRequest struct {
DiscNumber int `json:"disc_number"`
}
// LyricsDownloadResponse represents the response from lyrics download
type LyricsDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
@@ -66,27 +61,28 @@ type LyricsDownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
}
// LyricsClient handles lyrics fetching
type LyricsClient struct {
httpClient *http.Client
}
// NewLyricsClient creates a new lyrics client
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) {
// Try LRCLIB API
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase),
url.QueryEscape(artistName),
url.QueryEscape(trackName))
if duration > 0 {
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
}
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
@@ -107,11 +103,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*L
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
}
// Convert LRCLIB response to our LyricsResponse format
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
}
// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
resp := &LyricsResponse{
Error: false,
@@ -119,7 +113,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
Lines: []LyricsLine{},
}
// Prefer synced lyrics, fall back to plain
lyricsText := lrcLib.SyncedLyrics
if lyricsText == "" {
lyricsText = lrcLib.PlainLyrics
@@ -131,7 +124,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
return resp
}
// Parse synced lyrics format [mm:ss.xx] text
lines := strings.Split(lyricsText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
@@ -139,14 +131,12 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
continue
}
// Check if line has timestamp [mm:ss.xx]
if strings.HasPrefix(line, "[") && len(line) > 10 {
closeBracket := strings.Index(line, "]")
if closeBracket > 0 {
timestamp := line[1:closeBracket]
words := strings.TrimSpace(line[closeBracket+1:])
// Convert [mm:ss.xx] to milliseconds
ms := lrcTimestampToMs(timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
@@ -156,9 +146,8 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
}
}
// Plain lyrics line (no timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: "0",
StartTimeMs: "",
Words: line,
})
}
@@ -166,10 +155,9 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
return resp
}
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
func lrcTimestampToMs(timestamp string) int64 {
var minutes, seconds, centiseconds int64
// Try parsing mm:ss.xx format
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
@@ -177,7 +165,6 @@ func lrcTimestampToMs(timestamp string) int64 {
return 0
}
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
@@ -207,7 +194,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return nil, fmt.Errorf("no results found")
}
// Find best match - prefer one with synced lyrics
var best *LRCLibResponse
for i := range results {
if results[i].SyncedLyrics != "" {
@@ -226,41 +212,37 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return c.convertLRCLibToLyricsResponse(best), nil
}
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
func simplifyTrackName(name string) string {
// Remove content in parentheses
if idx := strings.Index(name, "("); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
// Remove content after " - " (like "From the Motion Picture")
if idx := strings.Index(name, " - "); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
return name
}
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
// 1. Try LRCLIB exact match
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil
}
fmt.Printf(" LRCLIB exact: %v\n", err)
// 2. Try LRCLIB search
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search", nil
}
fmt.Printf(" LRCLIB search: %v\n", err)
// 3. Try with simplified track name (remove parentheses, subtitles)
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB (simplified)", nil
}
@@ -274,31 +256,31 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return nil, "", fmt.Errorf("lyrics not found in any source")
}
// ConvertToLRC converts lyrics response to LRC format
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
var sb strings.Builder
// Add metadata
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
sb.WriteString("[by:SpotiFlac]\n")
sb.WriteString("\n")
// Add lyrics lines
for _, line := range lyrics.Lines {
if line.Words == "" {
continue
}
// Convert milliseconds to LRC timestamp format [mm:ss.xx]
if line.StartTimeMs == "" {
sb.WriteString(fmt.Sprintf("%s\n", line.Words))
} else {
timestamp := msToLRCTimestamp(line.StartTimeMs)
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
}
}
return sb.String()
}
// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx]
func msToLRCTimestamp(msStr string) string {
var ms int64
fmt.Sscanf(msStr, "%d", &ms)
@@ -311,14 +293,12 @@ func msToLRCTimestamp(msStr string) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
@@ -326,7 +306,6 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
@@ -335,34 +314,31 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
@@ -371,7 +347,47 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
return filename + ".lrc"
}
// DownloadLyrics downloads lyrics for a single track
func findAudioFileForLyrics(dir, trackName, artistName string) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"}
patterns := []string{
fmt.Sprintf("%s - %s", safeTitle, safeArtist),
fmt.Sprintf("%s - %s", safeArtist, safeTitle),
safeTitle,
}
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
for _, pattern := range patterns {
if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) {
ext := strings.ToLower(filepath.Ext(filename))
for _, audioExt := range audioExts {
if ext == strings.ToLower(audioExt) {
return filepath.Join(dir, filename)
}
}
}
}
}
return ""
}
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
if req.SpotifyID == "" {
return &LyricsDownloadResponse{
@@ -380,7 +396,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, fmt.Errorf("spotify ID is required")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
@@ -388,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,
@@ -395,15 +429,13 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
filenameFormat = "title-artist"
}
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &LyricsDownloadResponse{
Success: true,
@@ -413,8 +445,17 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, nil
}
// Fetch lyrics from LRCLIB
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
audioDuration := 0
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
if audioFile != "" {
duration, err := GetAudioDuration(audioFile)
if err == nil && duration > 0 {
audioDuration = int(duration)
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
}
}
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
if err != nil {
return &LyricsDownloadResponse{
Success: false,
@@ -422,10 +463,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err
}
// Convert to LRC format
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
// Write LRC file
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
return &LyricsDownloadResponse{
Success: false,
+476 -195
View File
@@ -1,13 +1,13 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
pathfilepath "path/filepath"
"strconv"
"strings"
"sync"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
@@ -20,12 +20,15 @@ type Metadata struct {
Artist string
Album string
AlbumArtist string
Date string // Recorded date (full date YYYY-MM-DD)
ReleaseDate string // Release date (full date) - kept for compatibility
Date string
ReleaseDate string
TrackNumber int
TotalTracks int // Total tracks in album
TotalTracks int
DiscNumber int
ISRC string
TotalDiscs int
URL string
Copyright string
Publisher string
Lyrics string
Description string
}
@@ -70,15 +73,21 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.DiscNumber > 0 {
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
if metadata.TotalDiscs > 0 {
_ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs))
}
if metadata.Copyright != "" {
_ = cmt.Add("COPYRIGHT", metadata.Copyright)
}
if metadata.Publisher != "" {
_ = cmt.Add("PUBLISHER", metadata.Publisher)
}
if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
// Lyrics is added last to keep it at the bottom
if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced
_ = cmt.Add("LYRICS", metadata.Lyrics)
}
cmtBlock := cmt.Marshal()
@@ -135,20 +144,17 @@ func fileExists(path string) bool {
return err == nil
}
// extractYear extracts the year from a release date string
// Handles formats: "YYYY-MM-DD", "YYYY-MM", "YYYY"
func extractYear(releaseDate string) string {
if releaseDate == "" {
return ""
}
// Try to extract year (first 4 digits)
if len(releaseDate) >= 4 {
return releaseDate[:4]
}
return releaseDate
}
// EmbedLyricsOnly adds lyrics to a FLAC file while preserving existing metadata
func EmbedLyricsOnly(filepath string, lyrics string) error {
if lyrics == "" {
return nil
@@ -171,10 +177,8 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
}
}
// Create new comment block, preserving existing comments
cmt := flacvorbis.New()
// Copy existing comments except LYRICS
if existingCmt != nil {
for _, comment := range existingCmt.Comments {
parts := strings.SplitN(comment, "=", 2)
@@ -187,7 +191,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
}
}
// Add lyrics
_ = cmt.Add("LYRICS", lyrics)
cmtBlock := cmt.Marshal()
@@ -204,82 +207,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
return nil
}
// ReadISRCFromFile reads ISRC metadata from a FLAC file
func ReadISRCFromFile(filepath string) (string, error) {
if !fileExists(filepath) {
return "", fmt.Errorf("file does not exist")
}
f, err := flac.ParseFile(filepath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
// Find VorbisComment block
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
// Get ISRC field
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
if err == nil && len(isrcValues) > 0 {
return isrcValues[0], nil
}
}
}
return "", nil // No ISRC found
}
// CheckISRCExists checks if a file with the given ISRC already exists in the directory
func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
if targetISRC == "" {
return "", false
}
// Read all .flac files in directory
entries, err := os.ReadDir(outputDir)
if err != nil {
return "", false
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// Check only .flac files
filename := entry.Name()
if len(filename) < 5 || filename[len(filename)-5:] != ".flac" {
continue
}
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
// Read ISRC from file (this will fail for corrupted files)
isrc, err := ReadISRCFromFile(filepath)
if err != nil {
// File is corrupted or unreadable, delete it
fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err)
if removeErr := os.Remove(filepath); removeErr != nil {
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr)
}
continue
}
// Compare ISRC (case-insensitive)
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
return filepath, true
}
}
return "", false
}
// ExtractCoverArt extracts cover art from an audio file and saves it to a temporary file
func ExtractCoverArt(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
@@ -293,7 +220,6 @@ func ExtractCoverArt(filePath string) (string, error) {
}
}
// extractCoverFromMp3 extracts cover art from MP3 file
func extractCoverFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
@@ -311,7 +237,6 @@ func extractCoverFromMp3(filePath string) (string, error) {
return "", fmt.Errorf("invalid picture frame")
}
// Create temporary file
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
@@ -326,7 +251,6 @@ func extractCoverFromMp3(filePath string) (string, error) {
return tmpFile.Name(), nil
}
// extractCoverFromM4AOrFlac extracts cover art from M4A or FLAC file
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
@@ -343,7 +267,6 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
continue
}
// Create temporary file
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
@@ -361,12 +284,9 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
return "", fmt.Errorf("no cover art found")
}
// For M4A, try to extract using ffmpeg or return empty
// M4A cover art should be preserved by ffmpeg during conversion
return "", nil
}
// ExtractLyrics extracts lyrics from an audio file
func ExtractLyrics(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
@@ -376,14 +296,13 @@ func ExtractLyrics(filePath string) (string, error) {
case ".flac":
return extractLyricsFromFlac(filePath)
case ".m4a":
// M4A lyrics extraction would need different approach
return "", nil
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
// extractLyricsFromMp3 extracts lyrics from MP3 file
func extractLyricsFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
@@ -412,7 +331,6 @@ func extractLyricsFromMp3(filePath string) (string, error) {
return uslt.Lyrics, nil
}
// extractLyricsFromFlac extracts lyrics from FLAC file
func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
@@ -426,7 +344,6 @@ func extractLyricsFromFlac(filePath string) (string, error) {
continue
}
// Search through comments for lyrics
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
@@ -445,7 +362,6 @@ func extractLyricsFromFlac(filePath string) (string, error) {
return "", nil
}
// EmbedCoverArtOnly embeds cover art into an audio file
func EmbedCoverArtOnly(filePath string, coverPath string) error {
if coverPath == "" || !fileExists(coverPath) {
return nil
@@ -457,16 +373,13 @@ func EmbedCoverArtOnly(filePath string, coverPath string) error {
case ".mp3":
return embedCoverToMp3(filePath, coverPath)
case ".m4a":
// M4A cover art should be handled by ffmpeg during conversion
// If not, we can try to embed using atomicparsley or similar tool
// For now, return nil as ffmpeg should handle it
return nil
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
// embedCoverToMp3 embeds cover art into MP3 file
func embedCoverToMp3(filePath string, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
@@ -474,16 +387,13 @@ func embedCoverToMp3(filePath string, coverPath string) error {
}
defer tag.Close()
// Remove existing cover art
tag.DeleteFrames(tag.CommonID("Attached picture"))
// Read cover art
artwork, err := os.ReadFile(coverPath)
if err != nil {
return fmt.Errorf("failed to read cover art: %w", err)
}
// Add new cover art
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
@@ -500,27 +410,30 @@ func embedCoverToMp3(filePath string, coverPath string) error {
return nil
}
// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
// Remove existing USLT frames
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
// Add new USLT frame with lyrics
// Use UTF-8 encoding for better compatibility with AIMP and other players
usltFrame := id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8, // Use UTF-8 instead of default encoding
Encoding: id3v2.EncodingUTF8,
Language: "eng",
ContentDescriptor: "", // Empty descriptor for better compatibility
ContentDescriptor: "",
Lyrics: lyrics,
}
tag.AddUnsynchronisedLyricsFrame(usltFrame)
@@ -532,27 +445,32 @@ func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
return nil
}
// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg
func embedLyricsToM4A(filepath string, lyrics string) error {
// Use ffmpeg to embed lyrics into M4A file
// M4A uses iTunes metadata format with atom '©lyr' for lyrics
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
// Create temporary output file with proper extension so ffmpeg can detect format
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
defer func() {
// Only remove if file still exists (rename might have failed)
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
// Use ffmpeg to copy file and add lyrics metadata
// For M4A, we need to use the correct metadata tag format and specify output format
// Use -f ipod for M4A format (iPod format is compatible with M4A)
cmd := exec.Command(ffmpegPath,
"-i", filepath,
"-map", "0",
@@ -560,12 +478,11 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
"-metadata", "lyrics-eng="+lyrics,
"-metadata", "lyrics="+lyrics,
"-codec", "copy",
"-f", "ipod", // Explicitly specify M4A/iPod format
"-y", // Overwrite
"-f", "ipod",
"-y",
tmpOutputFile,
)
// Hide console window on Windows
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
@@ -574,7 +491,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
}
// Replace original file with new file
if err := os.Rename(tmpOutputFile, filepath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
@@ -583,7 +499,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
return nil
}
// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
if lyrics == "" {
return nil
@@ -602,85 +517,451 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
}
}
// FileExistenceResult represents the result of checking if a file exists
type FileExistenceResult struct {
ISRC string `json:"isrc"`
Exists bool `json:"exists"`
FilePath string `json:"file_path,omitempty"`
TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"`
}
func GetAudioDuration(filepath string) (float64, error) {
ext := strings.ToLower(pathfilepath.Ext(filepath))
// CheckFilesExistParallel checks if multiple files exist in parallel
// It builds an ISRC index from the output directory once, then checks all tracks against it
func CheckFilesExistParallel(outputDir string, tracks []struct {
ISRC string
TrackName string
ArtistName string
}) []FileExistenceResult {
results := make([]FileExistenceResult, len(tracks))
// Build ISRC index from output directory (scan once)
isrcIndex := buildISRCIndex(outputDir)
// Check each track against the index (parallel)
var wg sync.WaitGroup
for i, track := range tracks {
wg.Add(1)
go func(idx int, t struct {
ISRC string
TrackName string
ArtistName string
}) {
defer wg.Done()
result := FileExistenceResult{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
Exists: false,
}
if t.ISRC != "" {
if filePath, exists := isrcIndex[strings.ToUpper(t.ISRC)]; exists {
result.Exists = true
result.FilePath = filePath
if ext == ".flac" {
duration, err := getFlacDuration(filepath)
if err == nil && duration > 0 {
return duration, nil
}
}
results[idx] = result
}(i, track)
return getDurationWithFFprobe(filepath)
}
wg.Wait()
return results
func getFlacDuration(filepath string) (float64, error) {
f, err := flac.ParseFile(filepath)
if err != nil {
return 0, err
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
func buildISRCIndex(outputDir string) map[string]string {
index := make(map[string]string)
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
totalSamples := uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if sampleRate > 0 {
return float64(totalSamples) / float64(sampleRate), nil
}
}
}
}
return 0, fmt.Errorf("could not extract duration from FLAC file")
}
func getDurationWithFFprobe(filepath string) (float64, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return 0, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return 0, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
filepath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return 0, err
}
var result struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(output, &result); err != nil {
return 0, err
}
if result.Format.Duration == "" {
return 0, fmt.Errorf("duration not found in ffprobe output")
}
duration, err := strconv.ParseFloat(result.Format.Duration, 64)
if err != nil {
return 0, err
}
return duration, nil
}
func validateLyricsDuration(lyrics string, filepath string) (string, error) {
duration, err := GetAudioDuration(filepath)
if err != nil {
fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err)
return lyrics, nil
}
if duration <= 0 {
fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration)
return lyrics, nil
}
durationMs := int64(duration * 1000)
lines := strings.Split(lyrics, "\n")
var validLines []string
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
validLines = append(validLines, line)
continue
}
if strings.HasPrefix(trimmedLine, "[") {
if strings.Index(trimmedLine, ":") > 0 {
validLines = append(validLines, line)
continue
}
closeBracket := strings.Index(trimmedLine, "]")
if closeBracket > 0 {
timestampStr := trimmedLine[1:closeBracket]
ms := parseLRCTimestamp(timestampStr)
if ms >= 0 && ms <= durationMs {
validLines = append(validLines, line)
} else {
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
}
} else {
validLines = append(validLines, line)
}
} else {
validLines = append(validLines, line)
}
}
return strings.Join(validLines, "\n"), nil
}
func parseLRCTimestamp(timestamp string) int64 {
var minutes, seconds, centiseconds int64
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return -1
}
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
var metadata Metadata
ffprobePath, err := GetFFprobePath()
if err != nil {
return metadata, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return metadata, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return metadata, err
}
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return metadata, err
}
allTags := make(map[string]string)
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
for key, value := range allTags {
switch key {
case "title":
metadata.Title = value
case "artist":
metadata.Artist = value
case "album":
metadata.Album = value
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "date", "year":
if metadata.Date == "" || len(value) > len(metadata.Date) {
metadata.Date = value
}
case "track":
parts := strings.Split(value, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[0]); err == nil {
metadata.TrackNumber = num
}
}
if len(parts) > 1 {
if num, err := strconv.Atoi(parts[1]); err == nil {
metadata.TotalTracks = num
}
}
case "disc":
parts := strings.Split(value, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[0]); err == nil {
metadata.DiscNumber = num
}
}
if len(parts) > 1 {
if num, err := strconv.Atoi(parts[1]); err == nil {
metadata.TotalDiscs = num
}
}
case "copyright", "tcop":
metadata.Copyright = value
case "publisher", "tpub", "label":
metadata.Publisher = value
case "url":
metadata.URL = value
case "description", "comment":
if metadata.Description == "" {
metadata.Description = value
}
}
}
return metadata, nil
}
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".flac":
return EmbedMetadata(filePath, metadata, coverPath)
case ".mp3":
return embedMetadataToMP3(filePath, metadata, coverPath)
case ".m4a":
return embedMetadataToM4A(filePath, metadata, coverPath)
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames("TXXX")
if metadata.Title != "" {
tag.SetTitle(metadata.Title)
}
if metadata.Artist != "" {
tag.SetArtist(metadata.Artist)
}
if metadata.Album != "" {
tag.SetAlbum(metadata.Album)
}
if metadata.Date != "" {
year := metadata.Date
if len(year) >= 4 {
year = year[:4]
}
tag.SetYear(year)
}
if metadata.AlbumArtist != "" {
tag.DeleteFrames("TPE2")
tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist)
}
if metadata.TrackNumber > 0 {
tag.DeleteFrames(tag.CommonID("Track number/Position in set"))
trackStr := strconv.Itoa(metadata.TrackNumber)
if metadata.TotalTracks > 0 {
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
}
tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr)
}
if metadata.DiscNumber > 0 {
tag.DeleteFrames(tag.CommonID("Part of a set"))
discStr := strconv.Itoa(metadata.DiscNumber)
if metadata.TotalDiscs > 0 {
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
}
tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr)
}
if metadata.Copyright != "" {
tag.DeleteFrames("TCOP")
tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright)
}
if metadata.Publisher != "" {
tag.DeleteFrames("TPUB")
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
}
if coverPath != "" && fileExists(coverPath) {
tag.DeleteFrames(tag.CommonID("Attached picture"))
artwork, err := os.ReadFile(coverPath)
if err == nil {
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: artwork,
}
tag.AddAttachedPicture(pic)
} else {
fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err)
}
}
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
// Walk directory recursively - only check .flac files for SpotiFLAC
pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(path))
if ext != ".flac" {
return nil
func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) error {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
// Read ISRC from file
isrc, err := ReadISRCFromFile(path)
if err != nil || isrc == "" {
return nil
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
// Store in index (uppercase for case-insensitive matching)
index[strings.ToUpper(isrc)] = path
return nil
})
return index
args := []string{
"-i", filePath,
"-y",
}
if coverPath != "" && fileExists(coverPath) {
args = append(args, "-i", coverPath)
args = append(args, "-map", "0:a", "-map", "1", "-c:a", "copy", "-c:v", "copy", "-disposition:v:0", "attached_pic")
} else {
args = append(args, "-map", "0", "-codec", "copy")
}
if metadata.Title != "" {
args = append(args, "-metadata", "title="+metadata.Title)
}
if metadata.Artist != "" {
args = append(args, "-metadata", "artist="+metadata.Artist)
}
if metadata.Album != "" {
args = append(args, "-metadata", "album="+metadata.Album)
}
if metadata.AlbumArtist != "" {
args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist)
}
if metadata.Date != "" {
args = append(args, "-metadata", "date="+metadata.Date)
}
if metadata.TrackNumber > 0 {
trackStr := strconv.Itoa(metadata.TrackNumber)
if metadata.TotalTracks > 0 {
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
}
args = append(args, "-metadata", "track="+trackStr)
}
if metadata.DiscNumber > 0 {
discStr := strconv.Itoa(metadata.DiscNumber)
if metadata.TotalDiscs > 0 {
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
}
args = append(args, "-metadata", "disk="+discStr)
}
if metadata.Copyright != "" {
args = append(args, "-metadata", "copyright="+metadata.Copyright)
}
if metadata.Publisher != "" {
args = append(args, "-metadata", "publisher="+metadata.Publisher)
}
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
defer func() {
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
args = append(args, "-f", "ipod", tmpOutputFile)
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg failed to embed metadata: %s - %w", string(output), err)
}
if err := os.Rename(tmpOutputFile, filePath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
return nil
}
+14 -54
View File
@@ -7,7 +7,6 @@ import (
"time"
)
// DownloadStatus represents the status of a download item
type DownloadStatus string
const (
@@ -18,7 +17,6 @@ const (
StatusSkipped DownloadStatus = "skipped"
)
// DownloadItem represents a single item in the download queue
type DownloadItem struct {
ID string `json:"id"`
TrackName string `json:"track_name"`
@@ -26,16 +24,15 @@ type DownloadItem struct {
AlbumName string `json:"album_name"`
ISRC string `json:"isrc"`
Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"` // MB downloaded
TotalSize float64 `json:"total_size"` // MB total (if known)
Speed float64 `json:"speed"` // MB/s
StartTime int64 `json:"start_time"` // Unix timestamp
EndTime int64 `json:"end_time"` // Unix timestamp
ErrorMessage string `json:"error_message"` // If failed
FilePath string `json:"file_path"` // Final file path
Progress float64 `json:"progress"`
TotalSize float64 `json:"total_size"`
Speed float64 `json:"speed"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
ErrorMessage string `json:"error_message"`
FilePath string `json:"file_path"`
}
// Global progress tracker
var (
currentProgress float64
currentProgressLock sync.RWMutex
@@ -44,7 +41,6 @@ var (
currentSpeed float64
speedLock sync.RWMutex
// Download queue tracking
downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex
currentItemID string
@@ -55,27 +51,24 @@ var (
sessionStartLock sync.RWMutex
)
// ProgressInfo represents download progress information
type ProgressInfo struct {
IsDownloading bool `json:"is_downloading"`
MBDownloaded float64 `json:"mb_downloaded"`
SpeedMBps float64 `json:"speed_mbps"`
}
// DownloadQueueInfo represents the complete download queue state
type DownloadQueueInfo struct {
IsDownloading bool `json:"is_downloading"`
Queue []DownloadItem `json:"queue"`
CurrentSpeed float64 `json:"current_speed"` // MB/s
TotalDownloaded float64 `json:"total_downloaded"` // MB this session
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
CurrentSpeed float64 `json:"current_speed"`
TotalDownloaded float64 `json:"total_downloaded"`
SessionStartTime int64 `json:"session_start_time"`
QueuedCount int `json:"queued_count"`
CompletedCount int `json:"completed_count"`
FailedCount int `json:"failed_count"`
SkippedCount int `json:"skipped_count"`
}
// GetDownloadProgress returns current download progress
func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock()
downloading := isDownloading
@@ -96,34 +89,30 @@ func GetDownloadProgress() ProgressInfo {
}
}
// SetDownloadSpeed updates the current download speed
func SetDownloadSpeed(mbps float64) {
speedLock.Lock()
currentSpeed = mbps
speedLock.Unlock()
}
// SetDownloadProgress updates the current download progress
func SetDownloadProgress(mbDownloaded float64) {
currentProgressLock.Lock()
currentProgress = mbDownloaded
currentProgressLock.Unlock()
}
// SetDownloading sets the downloading state
func SetDownloading(downloading bool) {
downloadingLock.Lock()
isDownloading = downloading
downloadingLock.Unlock()
if !downloading {
// Reset progress when download completes
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
}
// ProgressWriter wraps an io.Writer and reports download progress
type ProgressWriter struct {
writer io.Writer
total int64
@@ -131,7 +120,7 @@ type ProgressWriter struct {
startTime int64
lastTime int64
lastBytes int64
itemID string // Track which download item this belongs to
itemID string
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
@@ -147,7 +136,6 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
}
}
// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
pw := NewProgressWriter(writer)
pw.itemID = itemID
@@ -162,13 +150,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.total += int64(n)
// Report progress every 256KB for smoother updates
if pw.total-pw.lastPrinted >= 256*1024 {
mbDownloaded := float64(pw.total) / (1024 * 1024)
// Calculate speed (MB/s)
now := getCurrentTimeMillis()
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
timeDiff := float64(now-pw.lastTime) / 1000.0
bytesDiff := float64(pw.total - pw.lastBytes)
var speedMBps float64
@@ -180,10 +166,8 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
}
// Update global progress
SetDownloadProgress(mbDownloaded)
// Update individual item progress if we have an item ID
if pw.itemID != "" {
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
}
@@ -200,9 +184,6 @@ func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
// Queue management functions
// AddToQueue adds a new item to the download queue
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -223,7 +204,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueue = append(downloadQueue, item)
// Initialize session start time if this is the first item
sessionStartLock.Lock()
if sessionStartTime == 0 {
sessionStartTime = time.Now().Unix()
@@ -231,7 +211,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
sessionStartLock.Unlock()
}
// StartDownloadItem marks an item as currently downloading
func StartDownloadItem(id string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -250,7 +229,6 @@ func StartDownloadItem(id string) {
currentItemLock.Unlock()
}
// UpdateItemProgress updates the progress of the current download item
func UpdateItemProgress(id string, progress, speed float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -264,14 +242,12 @@ func UpdateItemProgress(id string, progress, speed float64) {
}
}
// GetCurrentItemID returns the ID of the currently downloading item
func GetCurrentItemID() string {
currentItemLock.RLock()
defer currentItemLock.RUnlock()
return currentItemID
}
// CompleteDownloadItem marks an item as completed
func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -284,7 +260,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueue[i].Progress = finalSize
downloadQueue[i].TotalSize = finalSize
// Add to total downloaded
totalDownloadedLock.Lock()
totalDownloaded += finalSize
totalDownloadedLock.Unlock()
@@ -293,7 +268,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
}
}
// FailDownloadItem marks an item as failed
func FailDownloadItem(id, errorMsg string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -308,7 +282,6 @@ func FailDownloadItem(id, errorMsg string) {
}
}
// SkipDownloadItem marks an item as skipped (already exists)
func SkipDownloadItem(id, filePath string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -323,9 +296,8 @@ func SkipDownloadItem(id, filePath string) {
}
}
// GetDownloadQueue returns the complete download queue state
func GetDownloadQueue() DownloadQueueInfo {
// Auto-reset session if all downloads are complete
ResetSessionIfComplete()
downloadQueueLock.RLock()
@@ -347,7 +319,6 @@ func GetDownloadQueue() DownloadQueueInfo {
sessionStart := sessionStartTime
sessionStartLock.RUnlock()
// Count statuses
var queued, completed, failed, skipped int
for _, item := range downloadQueue {
switch item.Status {
@@ -362,7 +333,6 @@ func GetDownloadQueue() DownloadQueueInfo {
}
}
// Create a copy of the queue
queueCopy := make([]DownloadItem, len(downloadQueue))
copy(queueCopy, downloadQueue)
@@ -379,12 +349,10 @@ func GetDownloadQueue() DownloadQueueInfo {
}
}
// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
func ClearDownloadQueue() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
// Keep only queued and downloading items
newQueue := make([]DownloadItem, 0)
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
@@ -394,7 +362,6 @@ func ClearDownloadQueue() {
downloadQueue = newQueue
}
// ClearAllDownloads clears the entire queue and resets session stats
func ClearAllDownloads() {
downloadQueueLock.Lock()
downloadQueue = []DownloadItem{}
@@ -412,13 +379,10 @@ func ClearAllDownloads() {
currentItemID = ""
currentItemLock.Unlock()
// Reset current progress and speed
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
// CancelAllQueuedItems marks all queued items as skipped (cancelled)
// This is called when user stops a download or when batch download completes
func CancelAllQueuedItems() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -432,8 +396,6 @@ func CancelAllQueuedItems() {
}
}
// ResetSessionIfComplete resets session stats if no active or queued downloads
// Note: Does NOT clear the queue - items remain visible for history
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
@@ -445,8 +407,6 @@ func ResetSessionIfComplete() {
}
downloadQueueLock.RUnlock()
// If no active or queued items, reset session stats
// But keep the queue items for history visibility
if !hasActiveOrQueued {
sessionStartLock.Lock()
sessionStartTime = 0
+59 -57
View File
@@ -78,7 +78,7 @@ func NewQobuzDownloader() *QobuzDownloader {
}
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
@@ -93,7 +93,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
var searchResp QobuzSearchResponse
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
@@ -104,7 +104,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
if err := json.Unmarshal(body, &searchResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -120,22 +120,19 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
// Map quality to Qobuz quality code
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
qualityCode := quality // Use the provided quality parameter
qualityCode := quality
if qualityCode == "" {
qualityCode = "6" // Default to FLAC 16-bit if not specified
qualityCode = "6"
}
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")
// Decode base64 API URLs
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
// Try primary API first
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 {
@@ -146,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
}
}
@@ -154,21 +151,43 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
resp.Body.Close()
}
// Fallback to secondary API
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)
@@ -180,11 +199,11 @@ 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 {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -193,19 +212,18 @@ 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
}
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
// Use a separate client with a longer timeout. The default client's 60s limit
// causes downloads to fail on slow connections or for large Hi-Res files.
downloadClient := &http.Client{
Timeout: 5 * time.Minute, // 5 minutes for large files
Timeout: 5 * time.Minute,
}
resp, err := downloadClient.Get(url)
@@ -226,14 +244,13 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
@@ -266,19 +283,16 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Determine track number to use
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
// Extract year from release date (format: YYYY-MM-DD or YYYY)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
// Check if format is a template (contains {})
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
@@ -287,34 +301,31 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
// Handle disc number
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
@@ -323,22 +334,20 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
// Create output directory if it doesn't exist
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.SearchByISRC(isrc)
track, err := q.SearchByISRC(deezerISRC)
if err != nil {
return "", err
}
// All metadata from Spotify - no fallback to Qobuz
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
@@ -362,7 +371,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
return "", fmt.Errorf("received empty download URL")
}
// Show partial URL for security
urlPreview := downloadURL
if len(downloadURL) > 60 {
urlPreview = downloadURL[:60] + "..."
@@ -374,13 +382,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC)
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", isrc, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings (use Spotify track number)
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
@@ -397,7 +398,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
if spotifyCoverURL != "" {
coverPath = filepath + ".cover.jpg"
coverClient := NewCoverClient()
@@ -412,23 +413,24 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Println("Embedding metadata and cover art...")
// Determine track number to embed - ALL from Spotify
trackNumberToEmbed := spotifyTrackNumber
if position > 0 && !useAlbumTrackNumber {
trackNumberToEmbed = position // Use playlist position
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
// ALL metadata from Spotify
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
ISRC: isrc, // ISRC from Spotify (passed as parameter)
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
-222
View File
@@ -1,222 +0,0 @@
package backend
import (
"strings"
"unicode"
)
// Hiragana to Romaji mapping
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",
// Dakuten (voiced)
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
// Handakuten (semi-voiced)
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
// Small characters
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'っ': "", // Double consonant marker
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
}
// Katakana to Romaji mapping
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",
// Dakuten (voiced)
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
// Handakuten (semi-voiced)
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
// Small characters
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ッ': "", // Double consonant marker
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
// Extended katakana
'ー': "", // Long vowel mark
'ヴ': "vu",
}
// Combination mappings for きゃ, しゃ, etc.
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",
// Extended combinations
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
}
// ContainsJapanese checks if a string contains Japanese characters
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) || // CJK Unified Ideographs
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
}
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) {
return text
}
var result strings.Builder
runes := []rune(text)
i := 0
for i < len(runes) {
// Check for っ/ッ (double consonant)
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]) // Double the first consonant
}
i++
continue
}
// Check for two-character combinations
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
}
}
// Single character conversion
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) {
// Keep kanji as-is (would need dictionary for proper conversion)
result.WriteRune(r)
} else {
// Keep other characters (punctuation, spaces, etc.)
result.WriteRune(r)
}
i++
}
return result.String()
}
// BuildSearchQuery creates a search query from track name and artist
// Converts Japanese to romaji if present
func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName)
artistRomaji := JapaneseToRomaji(artistName)
// Clean up the query - remove special characters that might interfere with search
trackClean := cleanSearchQuery(trackRomaji)
artistClean := cleanSearchQuery(artistRomaji)
return strings.TrimSpace(artistClean + " " + trackClean)
}
// cleanSearchQuery removes special characters that might interfere with search
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())
}
// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
// This is useful for creating search queries that work better with Tidal's search
func cleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
// Keep only ASCII letters, numbers, spaces, and basic punctuation
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
result.WriteRune(r)
} else if r == ',' || r == '.' {
// Convert punctuation to space
result.WriteRune(' ')
}
}
// Clean up multiple spaces
cleaned := strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(cleaned)
}
+151 -29
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"strings"
"time"
)
@@ -22,7 +23,6 @@ type SongLinkURLs struct {
AmazonURL string `json:"amazon_url"`
}
// TrackAvailability represents the availability of a track on different platforms
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
@@ -43,14 +43,13 @@ func NewSongLinkClient() *SongLinkClient {
}
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
@@ -61,7 +60,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
@@ -72,7 +70,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -86,7 +83,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
fmt.Println("Getting streaming URLs from song.link...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -95,7 +91,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return nil, fmt.Errorf("failed to get URLs: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
@@ -124,7 +119,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
URL string `json:"url"`
} `json:"linksByPlatform"`
}
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
@@ -135,7 +130,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -145,23 +140,20 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
urls := &SongLinkURLs{}
// Extract Tidal URL
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
urls.TidalURL = tidalLink.URL
fmt.Printf("✓ Tidal URL found\n")
}
// Extract Amazon URL
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if len(amazonURL) > 0 {
urls.AmazonURL = amazonURL
fmt.Printf("✓ Amazon URL found\n")
}
}
// Check if at least one URL was found
if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
@@ -169,16 +161,14 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return urls, nil
}
// CheckTrackAvailability checks the availability of a track on different platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
@@ -189,7 +179,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
@@ -200,7 +189,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -214,7 +202,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -223,7 +210,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return nil, fmt.Errorf("failed to check availability: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
@@ -252,7 +238,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
URL string `json:"url"`
} `json:"linksByPlatform"`
}
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
@@ -263,7 +249,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
@@ -275,33 +261,33 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Qobuz using ISRC (song.link doesn't support Qobuz)
if isrc != "" {
qobuzAvailable := checkQobuzAvailability(isrc)
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
deezerURL := deezerLink.URL
deezerISRC, err := GetDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" {
qobuzAvailable := checkQobuzAvailability(deezerISRC)
availability.Qobuz = qobuzAvailable
}
}
return availability, nil
}
// checkQobuzAvailability checks if a track is available on Qobuz using ISRC
func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
@@ -326,3 +312,139 @@ func checkQobuzAvailability(isrc string) bool {
return searchResp.Tracks.Total > 0
}
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Deezer URL from song.link...")
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
if !ok || deezerLink.URL == "" {
return "", fmt.Errorf("deezer link not found")
}
deezerURL := deezerLink.URL
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
return deezerURL, nil
}
func GetDeezerISRC(deezerURL string) (string, error) {
var trackID string
if strings.Contains(deezerURL, "/track/") {
parts := strings.Split(deezerURL, "/track/")
if len(parts) > 1 {
trackID = strings.Split(parts[1], "?")[0]
trackID = strings.TrimSpace(trackID)
}
}
if trackID == "" {
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL)
}
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return "", fmt.Errorf("failed to call Deezer API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
}
var deezerTrack struct {
ID int64 `json:"id"`
ISRC string `json:"isrc"`
Title string `json:"title"`
}
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
}
if deezerTrack.ISRC == "" {
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
}
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
return deezerTrack.ISRC, nil
}
+3 -17
View File
@@ -8,7 +8,6 @@ import (
"github.com/mewkiz/flac"
)
// SpectrumData contains frequency spectrum information
type SpectrumData struct {
TimeSlices []TimeSlice `json:"time_slices"`
SampleRate int `json:"sample_rate"`
@@ -17,15 +16,13 @@ type SpectrumData struct {
MaxFreq float64 `json:"max_freq"`
}
// TimeSlice represents spectrum data at a point in time
type TimeSlice struct {
Time float64 `json:"time"`
Magnitudes []float64 `json:"magnitudes"`
}
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
// Open FLAC file
stream, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
@@ -36,7 +33,6 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
sampleRate := int(info.SampleRate)
channels := int(info.NChannels)
// Read audio samples
samples, err := readSamples(stream, channels)
if err != nil {
return nil, fmt.Errorf("failed to read samples: %w", err)
@@ -46,28 +42,23 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
return nil, fmt.Errorf("no audio samples found")
}
// Calculate spectrum
return calculateSpectrum(samples, sampleRate), nil
}
// readSamples reads and decodes audio samples from FLAC stream
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
var allSamples []float64
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues
maxSamples := 10 * 1024 * 1024
// Decode frames
for {
frame, err := stream.ParseNext()
if err != nil {
// End of stream
break
}
// Convert samples to float64 and mix channels to mono
for i := 0; i < frame.Subframes[0].NSamples; i++ {
var sample float64
// Mix all channels to mono by averaging
for ch := 0; ch < channels; ch++ {
sample += float64(frame.Subframes[ch].Samples[i])
}
@@ -75,7 +66,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
allSamples = append(allSamples, sample)
// Limit sample count
if len(allSamples) >= maxSamples {
return allSamples, nil
}
@@ -85,7 +75,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
return allSamples, nil
}
// calculateSpectrum performs FFT analysis on audio samples
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
fftSize := 8192
numTimeSlices := 300
@@ -140,7 +129,6 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
}
}
// applyHannWindow applies Hann window to reduce spectral leakage
func applyHannWindow(samples []float64) []float64 {
n := len(samples)
windowed := make([]float64, n)
@@ -153,7 +141,6 @@ func applyHannWindow(samples []float64) []float64 {
return windowed
}
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
func fft(samples []float64) []complex128 {
n := len(samples)
@@ -165,7 +152,6 @@ func fft(samples []float64) []complex128 {
return fftRecursive(x)
}
// fftRecursive performs recursive FFT
func fftRecursive(x []complex128) []complex128 {
n := len(x)
+1711
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+150 -664
View File
File diff suppressed because it is too large Load Diff
+7 -8
View File
@@ -1,10 +1,9 @@
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']),
{
@@ -20,4 +19,4 @@ export default defineConfig([
globals: globals.browser,
},
},
])
]);
+6 -1
View File
@@ -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">
<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>
+6 -6
View File
@@ -27,7 +27,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
"motion": "^12.26.2",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
@@ -37,18 +37,18 @@
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/node": "^25.0.8",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"globals": "^17.0.0",
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.50.1",
"vite": "^7.3.0"
"typescript-eslint": "^8.53.0",
"vite": "^7.3.1"
}
}
+1 -1
View File
@@ -1 +1 @@
0f9764c2a4597a75120d3e76c32af7a9
68754ba75ba7fe058dd9ebf6593e2759
+602 -575
View File
File diff suppressed because it is too large Load Diff
+2 -11
View File
@@ -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) {
}
catch (error) {
console.error('✗ Failed to generate icon:', error.message);
process.exit(1);
}
}
generateIcon();
+128 -367
View File
@@ -1,22 +1,14 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
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";
// Components
import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar";
import { Header } from "@/components/Header";
@@ -33,18 +25,14 @@ import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import type { HistoryItem } from "@/components/FetchHistory";
// Hooks
import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5;
function App() {
const [currentPage, setCurrentPage] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -57,33 +45,39 @@ function App() {
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null);
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "7.0";
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);
// Initialize default download path if not set
if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
saveSettings(settingsWithDefaults);
await saveSettings(settingsWithDefaults);
}
};
initSettings();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
const currentSettings = getSettings();
@@ -92,27 +86,21 @@ function App() {
applyTheme(currentSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
loadHistory();
// Scroll listener for jump to top button
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300);
};
window.addEventListener("scroll", handleScroll);
return () => {
mediaQuery.removeEventListener("change", handleChange);
window.removeEventListener("scroll", handleScroll);
};
}, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
useEffect(() => {
setSelectedTracks([]);
setSearchQuery("");
@@ -123,46 +111,41 @@ function App() {
setSortBy("default");
setCurrentListPage(1);
}, [metadata.metadata]);
const checkForUpdates = async () => {
try {
const response = await fetch(
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
);
const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest");
const data = await response.json();
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
if (data.published_at) {
setReleaseDate(data.published_at);
}
if (latestVersion && latestVersion > CURRENT_VERSION) {
setHasUpdate(true);
}
} catch (err) {
}
catch (err) {
console.error("Failed to check for updates:", err);
}
};
const loadHistory = () => {
try {
const saved = localStorage.getItem(HISTORY_KEY);
if (saved) {
setFetchHistory(JSON.parse(saved));
}
} catch (err) {
}
catch (err) {
console.error("Failed to load history:", err);
}
};
const saveHistory = (history: HistoryItem[]) => {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
} catch (err) {
}
catch (err) {
console.error("Failed to save history:", err);
}
};
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
setFetchHistory((prev) => {
const filtered = prev.filter((h) => h.url !== item.url);
@@ -176,7 +159,6 @@ function App() {
return updated;
});
};
const removeFromHistory = (id: string) => {
setFetchHistory((prev) => {
const updated = prev.filter((h) => h.id !== id);
@@ -184,7 +166,6 @@ function App() {
return updated;
});
};
const handleHistorySelect = async (item: HistoryItem) => {
setSpotifyUrl(item.url);
const updatedUrl = await metadata.handleFetchMetadata(item.url);
@@ -192,19 +173,16 @@ function App() {
setSpotifyUrl(updatedUrl);
}
};
const handleFetchMetadata = async () => {
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
};
useEffect(() => {
if (!metadata.metadata || !spotifyUrl) return;
if (!metadata.metadata || !spotifyUrl)
return;
let historyItem: Omit<HistoryItem, "id" | "timestamp"> | null = null;
if ("track" in metadata.metadata) {
const { track } = metadata.metadata;
historyItem = {
@@ -214,331 +192,152 @@ function App() {
artist: track.artists,
image: track.images,
};
} else if ("album_info" in metadata.metadata) {
}
else if ("album_info" in metadata.metadata) {
const { album_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "album",
name: album_info.name,
artist: album_info.artists,
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
image: album_info.images,
};
} else if ("playlist_info" in metadata.metadata) {
}
else if ("playlist_info" in metadata.metadata) {
const { playlist_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "playlist",
name: playlist_info.owner.name,
artist: `${playlist_info.tracks.total} tracks${playlist_info.owner.display_name}`,
image: playlist_info.owner.images || "",
artist: `${playlist_info.tracks.total.toLocaleString()} tracks`,
image: playlist_info.cover || playlist_info.owner.images || "",
};
} else if ("artist_info" in metadata.metadata) {
}
else if ("artist_info" in metadata.metadata) {
const { artist_info } = metadata.metadata;
historyItem = {
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,
};
}
if (historyItem) {
addToHistory(historyItem);
}
}, [metadata.metadata]);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
setCurrentListPage(1);
};
const toggleTrackSelection = (isrc: string) => {
setSelectedTracks((prev) =>
prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]
);
setSelectedTracks((prev) => prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]);
};
const toggleSelectAll = (tracks: any[]) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
if (selectedTracks.length === tracksWithIsrc.length) {
setSelectedTracks([]);
} else {
}
else {
setSelectedTracks(tracksWithIsrc);
}
};
const handleOpenFolder = async () => {
const settings = getSettings();
if (!settings.downloadPath) {
toast.error("Download path not set");
return;
}
try {
await OpenFolder(settings.downloadPath);
} catch (error) {
}
catch (error) {
console.error("Error opening folder:", error);
toast.error(`Error opening folder: ${error}`);
}
};
const renderMetadata = () => {
if (!metadata.metadata) return null;
if (!metadata.metadata)
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)
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} />);
}
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}
/>
);
}
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)
}
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)
}
onCheckAvailability={availability.checkAvailability}
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, undefined, true)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onPageChange={setCurrentListPage}
onArtistClick={async (artist) => {
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}}
onTrackClick={async (track) => {
}} 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) => {
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) => {
}} 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) => {
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) => {
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}
/>
);
}} />);
}
return null;
};
const handlePageChange = (page: PageType) => {
if (currentPage === "settings" && hasUnsavedSettings && page !== "settings") {
setPendingPageChange(page);
setShowUnsavedChangesDialog(true);
return;
}
setCurrentPage(page);
};
const handleDiscardChanges = () => {
setShowUnsavedChangesDialog(false);
if (resetSettingsFn) {
resetSettingsFn();
}
const savedSettings = getSettings();
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
if (pendingPageChange) {
setCurrentPage(pendingPageChange);
setPendingPageChange(null);
}
};
const handleCancelNavigation = () => {
setShowUnsavedChangesDialog(false);
setPendingPageChange(null);
};
const renderPage = () => {
switch (currentPage) {
case "settings":
return <SettingsPage />;
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn} />;
case "debug":
return <DebugLoggerPage />;
case "audio-analysis":
@@ -548,27 +347,14 @@ function App() {
case "file-manager":
return <FileManagerPage />;
default:
return (
<>
<Header
version={CURRENT_VERSION}
hasUpdate={hasUpdate}
releaseDate={releaseDate}
/>
return (<>
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate} />
{/* Timeout Dialog */}
<Dialog
open={metadata.showTimeoutDialog}
onOpenChange={metadata.setShowTimeoutDialog}
>
<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)}
>
<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>
@@ -577,22 +363,13 @@ function App() {
Set timeout for fetching metadata. Longer timeout is recommended for artists
with large discography.
</DialogDescription>
{metadata.pendingArtistName && (
<div className="py-2">
{metadata.pendingArtistName && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
</div>
)}
</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))}
/>
<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).
@@ -600,10 +377,7 @@ function App() {
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => metadata.setShowTimeoutDialog(false)}
>
<Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
Cancel
</Button>
<Button onClick={metadata.handleConfirmFetch}>
@@ -614,16 +388,11 @@ function App() {
</DialogContent>
</Dialog>
{/* Album Fetch 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)}
>
<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>
@@ -631,11 +400,9 @@ function App() {
<DialogDescription>
Do you want to fetch metadata for this album?
</DialogDescription>
{metadata.selectedAlbum && (
<div className="py-2">
{metadata.selectedAlbum && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
</div>
)}
</div>)}
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
Cancel
@@ -653,67 +420,61 @@ function App() {
</DialogContent>
</Dialog>
<SearchBar
url={spotifyUrl}
loading={metadata.loading}
onUrlChange={setSpotifyUrl}
onFetch={handleFetchMetadata}
onFetchUrl={async (url) => {
<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()}
</>
);
</>);
}
};
return (
<TooltipProvider>
return (<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col">
<TitleBar />
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
{/* Main content area with sidebar offset */}
<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>
{/* Download Progress Toast - Bottom Left */}
<DownloadProgressToast onClick={downloadQueue.openQueue} />
{/* Download Queue Dialog */}
<DownloadQueue
isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue}
/>
{/* Jump to Top Button - Bottom Right */}
{showScrollTop && (
<Button
onClick={scrollToTop}
className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg"
size="icon"
>
<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>
)}
</div>
</TooltipProvider>
);
}
</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;
+34 -166
View File
@@ -7,7 +7,6 @@ import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
name: string;
@@ -29,18 +28,18 @@ interface AlbumInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
@@ -51,7 +50,7 @@ interface AlbumInfoProps {
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
@@ -62,212 +61,81 @@ interface AlbumInfoProps {
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function AlbumInfo({
albumInfo,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
onArtistClick,
onTrackClick,
}: AlbumInfoProps) {
return (
<div className="space-y-6">
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, }: AlbumInfoProps) {
return (<div className="space-y-6">
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{albumInfo.images && (
<img
src={albumInfo.images}
alt={albumInfo.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Album</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm">
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (
<span
className="font-medium cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onArtistClick({
id: albumInfo.artist_id!,
name: albumInfo.artists,
external_urls: albumInfo.artist_url!,
})
}
>
})}>
{albumInfo.artists}
</span>
) : (
<span className="font-medium">{albumInfo.artists}</span>
)}
</span>) : (<span className="font-medium">{albumInfo.artists}</span>)}
<span></span>
<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>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</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})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
variant="outline"
disabled={isBulkDownloadingLyrics}
>
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
variant="outline"
disabled={isBulkDownloadingCovers}
>
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>
)}
</Button>)}
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={true}
folderName={albumInfo.name}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onTrackClick={onTrackClick}
/>
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={true} folderName={albumInfo.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>
</div>
);
</div>);
}
+334 -198
View File
@@ -1,19 +1,28 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { useState } from "react";
interface ArtistInfoProps {
artistInfo: {
name: string;
images: string;
header?: string;
gallery?: string[];
followers: number;
genres: string[];
biography?: string;
verified?: boolean;
listeners?: number;
rank?: number;
};
albumList: Array<{
id: string;
@@ -34,18 +43,18 @@ interface ArtistInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
@@ -56,7 +65,7 @@ interface ArtistInfoProps {
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
@@ -66,250 +75,377 @@ interface ArtistInfoProps {
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
onAlbumClick: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onPageChange: (page: number) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function ArtistInfo({
artistInfo,
albumList,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onAlbumClick,
onArtistClick,
onPageChange,
onTrackClick,
}: ArtistInfoProps) {
return (
<div className="space-y-6">
<Card>
<CardContent className="px-6">
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, }: ArtistInfoProps) {
const [downloadingHeader, setDownloadingHeader] = useState(false);
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
const handleDownloadHeader = async () => {
if (!artistInfo.header)
return;
setDownloadingHeader(true);
try {
const settings = getSettings();
const response = await downloadHeader({
header_url: artistInfo.header,
artist_name: artistInfo.name,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info("Header already exists");
}
else {
toast.success("Header downloaded successfully");
}
}
else {
toast.error(response.error || "Failed to download header");
}
}
catch (error) {
toast.error(`Error downloading header: ${error}`);
}
finally {
setDownloadingHeader(false);
}
};
const handleDownloadAvatar = async () => {
if (!artistInfo.images)
return;
setDownloadingAvatar(true);
try {
const settings = getSettings();
const response = await downloadAvatar({
avatar_url: artistInfo.images,
artist_name: artistInfo.name,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info("Avatar already exists");
}
else {
toast.success("Avatar downloaded successfully");
}
}
else {
toast.error(response.error || "Failed to download avatar");
}
}
catch (error) {
toast.error(`Error downloading avatar: ${error}`);
}
finally {
setDownloadingAvatar(false);
}
};
const handleDownloadGalleryImage = async (imageUrl: string, index: number) => {
setDownloadingGalleryIndex(index);
try {
const settings = getSettings();
const response = await downloadGalleryImage({
image_url: imageUrl,
artist_name: artistInfo.name,
image_index: index,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info(`Gallery image ${index + 1} already exists`);
}
else {
toast.success(`Gallery image ${index + 1} downloaded successfully`);
}
}
else {
toast.error(response.error || `Failed to download gallery image ${index + 1}`);
}
}
catch (error) {
toast.error(`Error downloading gallery image ${index + 1}: ${error}`);
}
finally {
setDownloadingGalleryIndex(null);
}
};
const handleDownloadAllGallery = async () => {
if (!artistInfo.gallery || artistInfo.gallery.length === 0)
return;
setDownloadingAllGallery(true);
try {
const settings = getSettings();
let successCount = 0;
let existsCount = 0;
let failCount = 0;
for (let index = 0; index < artistInfo.gallery.length; index++) {
const imageUrl = artistInfo.gallery[index];
try {
const response = await downloadGalleryImage({
image_url: imageUrl,
artist_name: artistInfo.name,
image_index: index,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
existsCount++;
}
else {
successCount++;
}
}
else {
failCount++;
}
}
catch (error) {
failCount++;
}
}
if (failCount === 0) {
if (existsCount > 0 && successCount > 0) {
toast.success(`${successCount} images downloaded, ${existsCount} already existed`);
}
else if (existsCount > 0) {
toast.info(`All ${existsCount} images already exist`);
}
else {
toast.success(`All ${successCount} gallery images downloaded successfully`);
}
}
else {
toast.error(`${failCount} images failed to download`);
}
}
catch (error) {
toast.error(`Error downloading gallery images: ${error}`);
}
finally {
setDownloadingAllGallery(false);
}
};
return (<div className="space-y-6">
<Card className="overflow-hidden p-0">
{artistInfo.header ? (<>
<div className="relative w-full h-64 bg-cover bg-center">
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
<div className="absolute top-4 right-4 z-10">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingHeader ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Header</p>
</TooltipContent>
</Tooltip>
</div>
<div className="relative px-6 pt-6 pb-20">
<div className="flex gap-6 items-start">
{artistInfo.images && (
<img
src={artistInfo.images}
alt={artistInfo.name}
className="w-48 h-48 rounded-full shadow-lg object-cover"
/>
)}
{artistInfo.images && (<div className="relative group">
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Avatar</p>
</TooltipContent>
</Tooltip>
</div>
</div>)}
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-white/80">Artist</p>
<div className="flex items-center gap-2">
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-400 shrink-0"/>)}
</div>
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
<span>{artistInfo.followers.toLocaleString()} followers</span>
{artistInfo.listeners && (<>
<span></span>
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
</>)}
{artistInfo.rank && (<>
<span></span>
<span>#{artistInfo.rank} rank</span>
</>)}
<span></span>
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
<span></span>
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
{artistInfo.genres.length > 0 && (<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>)}
</div>
</div>
</div>
</div>
</div>
</>) : (<CardContent className="px-6 py-6">
<div className="flex gap-6 items-start">
{artistInfo.images && (<div className="relative group">
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Avatar</p>
</TooltipContent>
</Tooltip>
</div>
</div>)}
<div className="flex-1 space-y-2">
<p className="text-sm font-medium">Artist</p>
<div className="flex items-center gap-2">
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-500 shrink-0"/>)}
</div>
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap">
<span>{artistInfo.followers.toLocaleString()} followers</span>
{artistInfo.listeners && (<>
<span></span>
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
</>)}
{artistInfo.rank && (<>
<span></span>
<span>#{artistInfo.rank} rank</span>
</>)}
<span></span>
<span>{albumList.length} albums</span>
<span></span>
<span>{trackList.length} tracks</span>
{artistInfo.genres.length > 0 && (
<>
{artistInfo.genres.length > 0 && (<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>
)}
</>)}
</div>
</div>
</div>
</CardContent>
</CardContent>)}
</Card>
{albumList.length > 0 && (
<div className="space-y-4">
{artistInfo.gallery && artistInfo.gallery.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery.length})</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
{downloadingAllGallery ? <Spinner className="h-4 w-4"/> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Gallery</p>
</TooltipContent>
</Tooltip>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artistInfo.gallery.map((imageUrl, index) => (<div key={index} className="relative group">
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => handleDownloadGalleryImage(imageUrl, index)} size="sm" variant="secondary" disabled={downloadingGalleryIndex === index} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingGalleryIndex === index ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Image {index + 1}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>))}
</div>
</div>)}
{albumList.length > 0 && (<div className="space-y-4">
<h3 className="text-2xl font-bold">Discography</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{albumList.map((album) => (
<div
key={album.id}
className="group cursor-pointer"
onClick={() =>
onAlbumClick({
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
id: album.id,
name: album.name,
external_urls: album.external_urls,
})
}
>
})}>
<div className="relative mb-4">
{album.images && (
<img
src={album.images}
alt={album.name}
className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"
/>
)}
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
</div>
<h4 className="font-semibold truncate">{album.name}</h4>
<p className="text-sm text-muted-foreground">
{album.release_date?.split("-")[0]} {album.album_type}
{album.release_date?.split("-")[0]}
</p>
</div>))}
</div>
))}
</div>
</div>
)}
</div>)}
{trackList.length > 0 && (
<div className="space-y-4">
{trackList.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">Popular Tracks</h3>
<h3 className="text-2xl font-bold">All Tracks</h3>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</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})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
{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.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
size="sm"
variant="outline"
disabled={isBulkDownloadingLyrics}
>
<Button onClick={onDownloadAllLyrics} size="sm" variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
size="sm"
variant="outline"
disabled={isBulkDownloadingCovers}
>
<Button onClick={onDownloadAllCovers} size="sm" variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} size="sm" variant="outline">
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>
)}
</Button>)}
</div>
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={false}
folderName={artistInfo.name}
isArtistDiscography={true}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick}
onTrackClick={onTrackClick}
/>
</div>
)}
</div>
);
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>)}
</div>);
}
+17 -53
View File
@@ -1,18 +1,8 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
import {
Activity,
Waves,
Radio,
TrendingUp,
FileAudio,
Clock,
Gauge,
HardDrive
} from "lucide-react";
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
import type { AnalysisResult } from "@/types/api";
interface AudioAnalysisProps {
result: AnalysisResult | null;
analyzing: boolean;
@@ -20,30 +10,19 @@ interface AudioAnalysisProps {
showAnalyzeButton?: boolean;
filePath?: string;
}
export function AudioAnalysis({
result,
analyzing,
onAnalyze,
showAnalyzeButton = true,
filePath
}: AudioAnalysisProps) {
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
if (analyzing) {
return (
<Card>
return (<Card>
<CardContent className="px-6">
<div className="flex items-center justify-center py-8 gap-3">
<Spinner />
<span className="text-muted-foreground">Analyzing audio quality...</span>
</div>
</CardContent>
</Card>
);
</Card>);
}
if (!result && showAnalyzeButton) {
return (
<Card>
return (<Card>
<CardContent className="px-6">
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Activity className="h-12 w-12 text-primary"/>
@@ -53,53 +32,41 @@ export function AudioAnalysis({
Verify the true lossless quality of downloaded files
</p>
</div>
{onAnalyze && (
<Button onClick={onAnalyze}>
{onAnalyze && (<Button onClick={onAnalyze}>
<Activity className="h-4 w-4"/>
Analyze Audio
</Button>
)}
</Button>)}
</div>
</CardContent>
</Card>
);
</Card>);
}
if (!result) {
return null;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatNumber = (num: number) => {
return num.toFixed(2);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
// Calculate Nyquist frequency (half of sample rate)
const nyquistFreq = result.sample_rate / 2;
return (
<Card className="gap-2">
return (<Card className="gap-2">
<CardHeader>
{filePath && (
<p className="text-sm font-mono break-all">{filePath}</p>
)}
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
</CardHeader>
<CardContent className="space-y-2">
{/* Audio Properties - Single line */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground"/>
@@ -126,16 +93,14 @@ export function AudioAnalysis({
<span className="text-muted-foreground">Nyquist:</span>
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
</div>
{result.file_size > 0 && (
<div className="flex items-center gap-1">
{result.file_size > 0 && (<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Size:</span>
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
</div>
)}
</div>)}
</div>
{/* Dynamic Range - Single line */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
<div className="flex items-center gap-1">
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
@@ -156,6 +121,5 @@ export function AudioAnalysis({
</div>
</div>
</CardContent>
</Card>
);
</Card>);
}
+29 -71
View File
@@ -7,104 +7,77 @@ import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps {
onBack?: () => void;
}
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
await analyzeFile(filePath);
}
} catch (err) {
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0) return;
if (paths.length === 0)
return;
const filePath = paths[0];
if (!filePath.toLowerCase().endsWith(".flac")) {
toast.error("Invalid File Type", {
description: "Please drop a FLAC file for analysis",
});
return;
}
await analyzeFile(filePath);
},
[analyzeFile]
);
}, [analyzeFile]);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const handleAnalyzeAnother = () => {
clearResult();
};
return (<div className="space-y-6">
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack}>
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5"/>
</Button>
)}
</Button>)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div>
{result && (
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
<Trash2 className="h-4 w-4"/>
Clear
</Button>
)}
</Button>)}
</div>
{/* File Selection */}
{!result && !analyzing && (
<div
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${
isDragging
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"
}`}
onDragOver={(e) => {
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
}} onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
}} onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
@@ -117,39 +90,24 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
<Upload className="h-5 w-5"/>
Select FLAC File
</Button>
</div>
)}
</div>)}
{/* Loading State */}
{analyzing && !result && (
<div className="flex flex-col items-center justify-center py-16">
{analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>
)}
</div>)}
{result && (<div className="space-y-4">
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* Detailed Analysis */}
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
{/* Spectrum Visualization */}
{spectrumLoading ? (
<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
</div>
) : (
<SpectrumVisualization
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
)}
</div>
)}
</div>
);
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
</div>)}
</div>);
}
+127 -286
View File
@@ -1,30 +1,14 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group";
import {
Upload,
Download,
X,
CheckCircle2,
AlertCircle,
Trash2,
FileMusic,
WandSparkles,
} from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
import { Upload, Download, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
IsFFmpegInstalled,
DownloadFFmpeg,
ConvertAudio,
SelectAudioFiles,
} from "../../wailsjs/go/main/App";
import { IsFFmpegInstalled, DownloadFFmpeg, ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
interface AudioFile {
path: string;
name: string;
@@ -34,34 +18,30 @@ interface AudioFile {
error?: string;
outputPath?: string;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const BITRATE_OPTIONS = [
{ value: "320k", label: "320k" },
{ value: "256k", label: "256k" },
{ value: "192k", label: "192k" },
{ value: "128k", label: "128k" },
];
const M4A_CODEC_OPTIONS = [
{ value: "aac", label: "AAC" },
{ value: "alac", label: "ALAC" },
];
const STORAGE_KEY = "spotiflac_audio_converter_state";
export function AudioConverterPage() {
const [ffmpegInstalled, setFfmpegInstalled] = useState<boolean>(false);
const [installingFfmpeg, setInstallingFfmpeg] = useState(false);
const downloadProgress = useDownloadProgress();
const [files, setFiles] = useState<AudioFile[]>(() => {
// Initialize from sessionStorage synchronously
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
@@ -70,7 +50,8 @@ export function AudioConverterPage() {
return parsed.files;
}
}
} catch (err) {
}
catch (err) {
console.error("Failed to load saved state:", err);
}
return [];
@@ -84,8 +65,8 @@ export function AudioConverterPage() {
return parsed.outputFormat;
}
}
} catch (err) {
// Ignore
}
catch (err) {
}
return "mp3";
});
@@ -98,8 +79,8 @@ export function AudioConverterPage() {
return parsed.bitrate;
}
}
} catch (err) {
// Ignore
}
catch (err) {
}
return "320k";
});
@@ -112,87 +93,70 @@ export function AudioConverterPage() {
return parsed.m4aCodec;
}
}
} catch (err) {
// Ignore
}
catch (err) {
}
return "aac";
});
const [converting, setConverting] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Helper function to save state to sessionStorage
const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string; m4aCodec: "aac" | "alac" }) => {
const saveState = useCallback((stateToSave: {
files: AudioFile[];
outputFormat: "mp3" | "m4a";
bitrate: string;
m4aCodec: "aac" | "alac";
}) => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (err) {
}
catch (err) {
console.error("Failed to save state:", err);
}
}, []);
// Load saved state from sessionStorage on mount (only for ffmpeg check)
useEffect(() => {
checkFfmpegInstallation();
}, []);
// Save state to sessionStorage whenever files, outputFormat, bitrate, or m4aCodec changes
useEffect(() => {
saveState({ files, outputFormat, bitrate, m4aCodec });
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
// Auto-set output format to M4A if all files are MP3
useEffect(() => {
if (files.length === 0) return;
if (files.length === 0)
return;
const allMP3 = files.every((f) => f.format === "mp3");
if (allMP3 && outputFormat !== "m4a") {
setOutputFormat("m4a");
}
// Reset to AAC if no FLAC files (ALAC doesn't make sense for lossy input)
const hasFlac = files.some((f) => f.format === "flac");
if (!hasFlac && m4aCodec === "alac") {
setM4aCodec("aac");
}
}, [files, outputFormat, m4aCodec]);
// Check if format selection should be disabled (all files are MP3)
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
// Check if any file is FLAC (ALAC only makes sense for lossless input)
const hasFlacFiles = files.some((f) => f.format === "flac");
// Detect fullscreen/maximized window
useEffect(() => {
const checkFullscreen = () => {
// Check if window is maximized or fullscreen
// For Wails, we can check if window height is close to screen height
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
// Also check on window focus in case user maximizes externally
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const checkFfmpegInstallation = async () => {
try {
const installed = await IsFFmpegInstalled();
setFfmpegInstalled(installed);
} catch (err) {
}
catch (err) {
console.error("Failed to check ffmpeg:", err);
setFfmpegInstalled(false);
}
};
const handleInstallFfmpeg = async () => {
setInstallingFfmpeg(true);
try {
@@ -202,59 +166,52 @@ export function AudioConverterPage() {
description: "FFmpeg has been installed successfully",
});
setFfmpegInstalled(true);
} else {
}
else {
toast.error("Installation Failed", {
description: result.error || "Failed to install FFmpeg",
});
}
} catch (err) {
}
catch (err) {
toast.error("Installation Failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
}
finally {
setInstallingFfmpeg(false);
}
};
const handleSelectFiles = async () => {
try {
const selectedFiles = await SelectAudioFiles();
if (selectedFiles && selectedFiles.length > 0) {
addFiles(selectedFiles);
}
} catch (err) {
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select files",
});
}
};
const addFiles = useCallback(async (paths: string[]) => {
const validExtensions = [".mp3", ".flac"];
// Check for M4A files specifically
const m4aFiles = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return ext === ".m4a";
});
if (m4aFiles.length > 0) {
toast.error("M4A files not supported", {
description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
});
}
// Get file sizes from backend
const GetFileSizes = (files: string[]): Promise<Record<string, number>> =>
(window as any)["go"]["main"]["App"]["GetFileSizes"](files);
const GetFileSizes = (files: string[]): Promise<Record<string, number>> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files);
const validPaths = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return validExtensions.includes(ext);
});
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
setFiles((prev) => {
const newFiles: AudioFile[] = validPaths
.filter((path) => !prev.some((f) => f.path === path))
@@ -269,7 +226,6 @@ export function AudioConverterPage() {
status: "pending" as const,
};
});
if (newFiles.length > 0) {
if (paths.length > newFiles.length) {
const skipped = paths.length - newFiles.length;
@@ -277,53 +233,38 @@ export function AudioConverterPage() {
description: `${skipped} file(s) were skipped (unsupported format or already added)`,
});
}
return [...prev, ...newFiles];
}
if (paths.length > 0 && m4aFiles.length === 0) {
toast.info("No new files added", {
description: "All files were already added or have unsupported format",
});
}
return prev;
});
}, []);
const handleFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0) return;
if (paths.length === 0)
return;
addFiles(paths);
},
[addFiles]
);
}, [addFiles]);
useEffect(() => {
// Only enable drag and drop for audio files if FFmpeg is installed
if (ffmpegInstalled === true) {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}
}, [handleFileDrop, ffmpegInstalled]);
const removeFile = (path: string) => {
setFiles((prev) => prev.filter((f) => f.path !== path));
};
const clearFiles = () => {
setFiles([]);
};
const handleConvert = async () => {
if (files.length === 0) {
toast.error("No files selected", {
@@ -331,33 +272,22 @@ export function AudioConverterPage() {
});
return;
}
setConverting(true);
try {
// Include all files (including previously successful ones) for conversion
const inputPaths = files.map((f) => f.path);
// Mark all files as converting (including previously successful ones)
setFiles((prev) =>
prev.map((f) => {
setFiles((prev) => prev.map((f) => {
if (inputPaths.includes(f.path)) {
return { ...f, status: "converting" as const, error: undefined };
}
return f;
})
);
}));
const results = await ConvertAudio({
input_files: inputPaths,
output_format: outputFormat,
bitrate: bitrate,
codec: outputFormat === "m4a" ? m4aCodec : "",
});
// Update file statuses based on results
setFiles((prev) =>
prev.map((f) => {
setFiles((prev) => prev.map((f) => {
const result = results.find((r) => r.input_file === f.path);
if (result) {
return {
@@ -368,33 +298,30 @@ export function AudioConverterPage() {
};
}
return f;
})
);
}));
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
if (successCount > 0) {
toast.success("Conversion Complete", {
description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
});
} else if (failCount > 0) {
}
else if (failCount > 0) {
toast.error("Conversion Failed", {
description: `All ${failCount} file(s) failed to convert`,
});
}
} catch (err) {
}
catch (err) {
toast.error("Conversion Error", {
description: err instanceof Error ? err.message : "Unknown error",
});
setFiles((prev) =>
prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" }))
);
} finally {
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" })));
}
finally {
setConverting(false);
}
};
const getStatusIcon = (status: AudioFile["status"]) => {
switch (status) {
case "converting":
@@ -407,101 +334,76 @@ export function AudioConverterPage() {
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
}
};
// Count files that can be converted (pending + success files that can be re-converted)
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
const successCount = files.filter((f) => f.status === "success").length;
// Show FFmpeg installation prompt if not installed
if (ffmpegInstalled === false) {
return (
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Audio Converter</h1>
</div>
<div
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${
isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"
} border-muted-foreground/30`}
>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} border-muted-foreground/30`}>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Download className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
FFmpeg is required to convert audio files
</p>
<Button
onClick={handleInstallFfmpeg}
disabled={installingFfmpeg}
size="lg"
>
{installingFfmpeg ? (
<>
<Button onClick={handleInstallFfmpeg} disabled={installingFfmpeg} size="lg">
{installingFfmpeg ? (<>
<Spinner className="h-5 w-5"/>
Installing FFmpeg...
</>
) : (
<>
</>) : (<>
<Download className="h-5 w-5"/>
Install FFmpeg
</>
)}
</>)}
</Button>
</div>
</div>
);
}
return (
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
{/* Header */}
{installingFfmpeg && downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<div className="w-full max-w-md mt-6 space-y-2 px-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Downloading FFmpeg</span>
<span className="font-mono tabular-nums">
{downloadProgress.mb_downloaded.toFixed(2)} MB
{downloadProgress.speed_mbps > 0 && (<span className="text-muted-foreground ml-2">
@ {downloadProgress.speed_mbps.toFixed(2)} MB/s
</span>)}
</span>
</div>
<Progress value={Math.min(100, (downloadProgress.mb_downloaded / 200) * 100)} className="h-2"/>
</div>)}
</div>
</div>);
}
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Audio Converter</h1>
{files.length > 0 && (
<div className="flex gap-2">
{files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/>
Add More
</Button>
<Button
variant="outline"
size="sm"
onClick={clearFiles}
disabled={converting}
>
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
<Trash2 className="h-4 w-4"/>
Clear All
</Button>
</div>
)}
</div>)}
</div>
{/* Drop Zone / File List */}
<div
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${
isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"
} ${
isDragging
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"
}`}
onDragOver={(e) => {
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
}} onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
}} onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
{files.length === 0 ? (
<>
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
{files.length === 0 ? (<>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
@@ -517,107 +419,68 @@ export function AudioConverterPage() {
<p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3
</p>
</>
) : (
<div className="w-full h-full p-6 space-y-4 flex flex-col">
{/* Settings Row - Only show when files exist */}
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
<div className="space-y-2 pb-4 border-b shrink-0">
{/* Format and Bitrate in one line */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Format:</Label>
<ToggleGroup
type="single"
variant="outline"
value={outputFormat}
onValueChange={(value) => {
if (value && !isFormatDisabled) setOutputFormat(value as "mp3" | "m4a");
}}
disabled={isFormatDisabled}
>
{!isFormatDisabled && (
<ToggleGroupItem value="mp3" aria-label="MP3">
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
if (value && !isFormatDisabled)
setOutputFormat(value as "mp3" | "m4a");
}} disabled={isFormatDisabled}>
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
MP3
</ToggleGroupItem>
)}
</ToggleGroupItem>)}
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
M4A
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Codec selection for M4A - only show ALAC option when input has FLAC files */}
{outputFormat === "m4a" && hasFlacFiles && (
<div className="flex items-center gap-2">
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Codec:</Label>
<ToggleGroup
type="single"
variant="outline"
value={m4aCodec}
onValueChange={(value) => {
if (value) setM4aCodec(value as "aac" | "alac");
}}
>
{M4A_CODEC_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
>
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
if (value)
setM4aCodec(value as "aac" | "alac");
}}>
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroupItem>))}
</ToggleGroup>
</div>
)}
{/* Bitrate selection - hide for ALAC (lossless) */}
{!(outputFormat === "m4a" && m4aCodec === "alac") && (
<div className="flex items-center gap-2">
</div>)}
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Bitrate:</Label>
<ToggleGroup
type="single"
variant="outline"
value={bitrate}
onValueChange={(value) => {
if (value) setBitrate(value);
}}
>
{BITRATE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
>
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
if (value)
setBitrate(value);
}}>
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroupItem>))}
</ToggleGroup>
</div>
)}
</div>)}
</div>
</div>
{/* File List Header */}
<div className="flex items-center justify-between shrink-0">
<div className="text-sm text-muted-foreground">
{files.length} file(s) {successCount} converted
</div>
</div>
{/* File List */}
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
{files.map((file) => (
<div
key={file.path}
className="flex items-center gap-3 rounded-lg border p-3"
>
{files.map((file) => (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
{getStatusIcon(file.status)}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.error && (
<p className="truncate text-xs text-destructive">
{file.error && (<p className="truncate text-xs text-destructive">
{file.error}
</p>
)}
</p>)}
</div>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
@@ -625,47 +488,25 @@ export function AudioConverterPage() {
<span className="text-xs uppercase text-muted-foreground">
{file.format}
</span>
{file.status !== "converting" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeFile(file.path)}
disabled={converting}
>
{file.status !== "converting" && (<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeFile(file.path)} disabled={converting}>
<X className="h-4 w-4"/>
</Button>
)}
</div>
))}
</Button>)}
</div>))}
</div>
{/* Convert Button */}
<div className="flex justify-center pt-4 border-t shrink-0">
<Button
onClick={handleConvert}
disabled={converting || convertableCount === 0}
size="lg"
>
{converting ? (
<>
<Button onClick={handleConvert} disabled={converting || convertableCount === 0} size="lg">
{converting ? (<>
<Spinner className="h-4 w-4"/>
Converting...
</>
) : (
<>
</>) : (<>
<WandSparkles className="h-4 w-4"/>
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
</>
)}
</>)}
</Button>
</div>
</div>)}
</div>
)}
</div>
</div>
);
</div>);
}
+9 -40
View File
@@ -2,7 +2,6 @@ import { useState, useEffect, useRef } from "react";
import { Trash2, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { logger, type LogEntry } from "@/lib/logger";
const levelColors: Record<string, string> = {
info: "text-blue-500",
success: "text-green-500",
@@ -10,7 +9,6 @@ const levelColors: Record<string, string> = {
error: "text-red-500",
debug: "text-gray-500",
};
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour12: false,
@@ -19,12 +17,10 @@ function formatTime(date: Date): string {
second: "2-digit",
});
}
export function DebugLoggerPage() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [copied, setCopied] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const unsubscribe = logger.subscribe(() => {
setLogs(logger.getLogs());
@@ -34,68 +30,44 @@ export function DebugLoggerPage() {
unsubscribe();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
};
const handleCopy = async () => {
const logText = logs
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
.join("\n");
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (err) {
}
catch (err) {
console.error("Failed to copy logs:", err);
}
};
return (
<div className="space-y-6">
return (<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleCopy}
disabled={logs.length === 0}
>
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleClear}
disabled={logs.length === 0}
>
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleClear} disabled={logs.length === 0}>
<Trash2 className="h-4 w-4"/>
Clear
</Button>
</div>
</div>
<div
ref={scrollRef}
className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs"
>
{logs.length === 0 ? (
<p className="text-muted-foreground lowercase">no logs yet...</p>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5">
<div ref={scrollRef} className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs">
{logs.length === 0 ? (<p className="text-muted-foreground lowercase">no logs yet...</p>) : (logs.map((log, i) => (<div key={i} className="flex gap-2 py-0.5">
<span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}]
</span>
@@ -103,10 +75,7 @@ export function DebugLoggerPage() {
[{log.level}]
</span>
<span className="break-all">{log.message}</span>
</div>)))}
</div>
))
)}
</div>
</div>
);
</div>);
}
+6 -7
View File
@@ -1,17 +1,17 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react";
interface DownloadProgressProps {
progress: number;
currentTrack: { name: string; artists: string } | null;
currentTrack: {
name: string;
artists: string;
} | null;
onStop: () => void;
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (
<div className="w-full space-y-2 mt-4">
return (<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={clampedProgress} className="h-2 flex-1"/>
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
@@ -25,6 +25,5 @@ export function DownloadProgress({ progress, currentTrack, onStop }: DownloadPro
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>
</div>
);
</div>);
}
@@ -2,47 +2,30 @@ import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
import { Download, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DownloadProgressToastProps {
onClick: () => void;
}
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
const progress = useDownloadProgress();
const queueInfo = useDownloadQueueData();
// Show indicator if there are any queued or downloading items
// Don't show for completed/failed/skipped only
const hasActiveDownloads = queueInfo.queue.some(
item => item.status === "queued" || item.status === "downloading"
);
const hasActiveDownloads = queueInfo.queue.some(item => item.status === "queued" || item.status === "downloading");
if (!hasActiveDownloads) {
return null;
}
return (
<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<Button
variant="outline"
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
onClick={onClick}
>
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<Button variant="outline" className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer" onClick={onClick}>
<div className="flex items-center gap-3">
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
<div className="flex flex-col min-w-[80px]">
<p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB
</p>
{progress.speed_mbps > 0 && (
<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps.toFixed(2)} MB/s
</p>
)}
</p>)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
</div>
</Button>
</div>
);
</div>);
}
+57 -101
View File
@@ -1,25 +1,17 @@
import { useEffect, useState } from "react";
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { 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;
onClose: () => void;
}
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
new backend.DownloadQueueInfo({
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
@@ -29,41 +21,44 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
completed_count: 0,
failed_count: 0,
skipped_count: 0,
})
);
}));
useEffect(() => {
if (!isOpen) return;
if (!isOpen)
return;
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
}
catch (error) {
console.error("Failed to get download queue:", error);
}
};
// Initial fetch
fetchQueue();
// Poll every 500ms when dialog is open
const interval = setInterval(fetchQueue, 500);
return () => clearInterval(interval);
}, [isOpen]);
const handleClearHistory = async () => {
try {
await ClearCompletedDownloads();
// Refetch immediately to update UI
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
}
catch (error) {
console.error("Failed to clear history:", error);
}
};
const handleReset = async () => {
try {
await ClearAllDownloads();
const info = await GetDownloadQueue();
setQueueInfo(info);
toast.success("Download queue reset");
}
catch (error) {
console.error("Failed to reset queue:", error);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "downloading":
@@ -80,7 +75,6 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
return null;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
downloading: "default",
@@ -89,63 +83,45 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
skipped: "secondary",
queued: "outline",
};
return (
<Badge variant={variants[status] || "outline"} className="text-xs">
return (<Badge variant={variants[status] || "outline"} className="text-xs">
{status}
</Badge>
);
</Badge>);
};
// Format session duration
const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0) return "—";
if (startTimestamp === 0)
return "—";
const now = Math.floor(Date.now() / 1000);
const durationSeconds = now - startTimestamp;
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
}
else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
}
else {
return `${seconds}s`;
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
return (<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<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>
<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}
>
{(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}
>
</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>
{/* Queue Status */}
<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"/>
@@ -169,7 +145,7 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
</div>
</div>
{/* Session Stats */}
<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"/>
@@ -198,20 +174,13 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
</DialogHeader>
{/* Download Queue List */}
<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">
{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>) : (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>
@@ -227,9 +196,8 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
{getStatusBadge(item.status)}
</div>
{/* Info for downloading items */}
{item.status === "downloading" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
{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`
@@ -244,44 +212,32 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
)}
</div>)}
{/* Completed info */}
{item.status === "completed" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
{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>
)}
</div>)}
{/* Skipped info */}
{item.status === "skipped" && (
<div className="mt-1.5 text-xs text-muted-foreground">
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
File already exists
</div>
)}
</div>)}
{/* Error message */}
{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.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>
)}
</div>)}
{/* File path for completed/skipped */}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (
<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>
)}
</div>)}
</div>
</div>
</div>
))
)}
</div>)))}
</div>
</div>
</DialogContent>
</Dialog>
);
</Dialog>);
}
+47 -42
View File
@@ -1,5 +1,4 @@
import { X } from "lucide-react";
import { X, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
export interface HistoryItem {
id: string;
url: string;
@@ -9,16 +8,14 @@ export interface HistoryItem {
image: string;
timestamp: number;
}
interface FetchHistoryProps {
history: HistoryItem[];
onSelect: (item: HistoryItem) => void;
onRemove: (id: string) => void;
}
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
if (history.length === 0) return null;
if (history.length === 0)
return null;
const getTypeLabel = (type: string) => {
switch (type) {
case "track":
@@ -33,59 +30,67 @@ export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps)
return type;
}
};
return (
<div className="space-y-2">
<span className="text-sm text-muted-foreground">Recent Fetches</span>
const getTypeIcon = (type: string) => {
switch (type) {
case "track":
return Music2;
case "album":
return Disc3;
case "playlist":
return ListMusic;
case "artist":
return UserRound;
default:
return null;
}
};
const getTypeBadgeClass = (type: string) => {
switch (type) {
case "track":
return "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400";
case "album":
return "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400";
case "playlist":
return "bg-purple-500/10 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400";
case "artist":
return "bg-orange-500/10 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400";
default:
return "bg-muted text-muted-foreground";
}
};
return (<div className="space-y-2">
<span className="text-sm text-muted-foreground">{history.length === 1 ? "Recent Fetch" : "Recent Fetches"}</span>
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
{history.map((item) => (
<div
key={item.id}
className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible"
onClick={() => onSelect(item)}
>
<button
type="button"
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
onClick={(e) => {
{history.map((item) => (<div key={item.id} className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible" onClick={() => onSelect(item)}>
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
e.stopPropagation();
onRemove(item.id);
}}
>
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
<div className="p-2">
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
{item.image ? (
<img
src={item.image}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
{item.image ? (<img src={item.image} alt={item.name} className="w-full h-full object-cover"/>) : (<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
No Image
</div>
)}
</div>)}
</div>
<div className="space-y-0.5">
<p className="text-xs font-medium truncate" title={item.name}>
{item.name}
</p>
<p
className="text-xs text-muted-foreground truncate"
title={item.artist}
>
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
{item.artist}
</p>
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{(() => {
const IconComponent = getTypeIcon(item.type);
return (<span className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded ${getTypeBadgeClass(item.type)}`}>
{IconComponent ? <IconComponent className="h-2.5 w-2.5"/> : null}
{getTypeLabel(item.type)}
</span>
</span>);
})()}
</div>
</div>
</div>))}
</div>
))}
</div>
</div>
);
</div>);
}
+221 -260
View File
@@ -3,63 +3,29 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FolderOpen,
RefreshCw,
FileMusic,
ChevronRight,
ChevronDown,
Pencil,
Eye,
Folder,
Info,
RotateCcw,
FileText,
Image,
Copy,
Check,
} from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { FolderOpen, RefreshCw, FileMusic, ChevronRight, ChevronDown, Pencil, Eye, Folder, Info, RotateCcw, FileText, Image, Copy, Check, } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { getSettings } from "@/lib/settings";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> =>
(window as any)['go']['main']['App']['ListDirectoryFiles'](path);
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> =>
(window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> =>
(window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> =>
(window as any)['go']['main']['App']['ReadFileMetadata'](path);
const IsFFprobeInstalled = (): Promise<boolean> =>
(window as any)['go']['main']['App']['IsFFprobeInstalled']();
const DownloadFFmpeg = (): Promise<{ success: boolean; message: string; error?: string }> =>
(window as any)['go']['main']['App']['DownloadFFmpeg']();
const ReadTextFile = (path: string): Promise<string> =>
(window as any)['go']['main']['App']['ReadTextFile'](path);
const RenameFileTo = (oldPath: string, newName: string): Promise<void> =>
(window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
const ReadImageAsBase64 = (path: string): Promise<string> =>
(window as any)['go']['main']['App']['ReadImageAsBase64'](path);
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> => (window as any)['go']['main']['App']['ListDirectoryFiles'](path);
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path);
const IsFFprobeInstalled = (): Promise<boolean> => (window as any)['go']['main']['App']['IsFFprobeInstalled']();
const DownloadFFmpeg = (): Promise<{
success: boolean;
message: string;
error?: string;
}> => (window as any)['go']['main']['App']['DownloadFFmpeg']();
const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path);
const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
interface FileNode {
name: string;
path: string;
@@ -68,7 +34,6 @@ interface FileNode {
children?: FileNode[];
expanded?: boolean;
}
interface FileMetadata {
title: string;
artist: string;
@@ -78,10 +43,11 @@ interface FileMetadata {
disc_number: number;
year: string;
}
type TabType = "track" | "lyric" | "cover";
const FORMAT_PRESETS: Record<string, { label: string; template: string }> = {
const FORMAT_PRESETS: Record<string, {
label: string;
template: string;
}> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
@@ -96,19 +62,17 @@ const FORMAT_PRESETS: Record<string, { label: string; template: string }> = {
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
const STORAGE_KEY = "spotiflac_file_manager_state";
const DEFAULT_PRESET = "title-artist";
const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
export function FileManagerPage() {
const [rootPath, setRootPath] = useState(() => {
const settings = getSettings();
@@ -127,7 +91,8 @@ export function FileManagerPage() {
return parsed.formatPreset;
}
}
} catch { /* ignore */ }
}
catch { }
return DEFAULT_PRESET;
});
const [customFormat, setCustomFormat] = useState(() => {
@@ -135,12 +100,13 @@ export function FileManagerPage() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.customFormat) return parsed.customFormat;
if (parsed.customFormat)
return parsed.customFormat;
}
} catch { /* ignore */ }
}
catch { }
return DEFAULT_CUSTOM_FORMAT;
});
const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template;
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<backend.RenamePreview[]>([]);
@@ -166,13 +132,12 @@ export function FileManagerPage() {
const [manualRenameFile, setManualRenameFile] = useState("");
const [manualRenameName, setManualRenameName] = useState("");
const [manualRenaming, setManualRenaming] = useState(false);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
} catch { /* ignore */ }
}
catch { }
}, [formatPreset, customFormat]);
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
@@ -186,7 +151,6 @@ export function FileManagerPage() {
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => {
return nodes
.map((node) => {
@@ -198,16 +162,19 @@ export function FileManagerPage() {
return null;
}
const ext = node.name.toLowerCase();
if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a"))) return node;
if (type === "lyric" && ext.endsWith(".lrc")) return node;
if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png"))) return node;
if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")))
return node;
if (type === "lyric" && ext.endsWith(".lrc"))
return node;
if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png")))
return node;
return null;
})
.filter((node): node is FileNode => node !== null);
};
const loadFiles = useCallback(async () => {
if (!rootPath) return;
if (!rootPath)
return;
setLoading(true);
try {
const result = await ListDirectoryFiles(rootPath);
@@ -218,113 +185,126 @@ export function FileManagerPage() {
}
setAllFiles(result as FileNode[]);
setSelectedFiles(new Set());
} catch (err) {
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err || "");
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
toast.error("Failed to load files", { description: errorMsg || "Unknown error" });
}
setAllFiles([]);
setSelectedFiles(new Set());
} finally {
}
finally {
setLoading(false);
}
}, [rootPath]);
useEffect(() => {
if (rootPath) loadFiles();
if (rootPath)
loadFiles();
}, [rootPath, loadFiles]);
const filteredFiles = filterFilesByType(allFiles, activeTab);
const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => {
const result: FileNode[] = [];
for (const node of nodes) {
if (!node.is_dir) result.push(node);
if (node.children) result.push(...getAllFilesFlat(node.children));
if (!node.is_dir)
result.push(node);
if (node.children)
result.push(...getAllFilesFlat(node.children));
}
return result;
};
const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track"));
const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric"));
const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover"));
const handleSelectFolder = async () => {
try {
const path = await SelectFolder(rootPath);
if (path) setRootPath(path);
} catch (err) {
if (path)
setRootPath(path);
}
catch (err) {
toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const toggleExpand = (path: string) => {
setAllFiles((prev) => toggleNodeExpand(prev, path));
};
const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => {
return nodes.map((node) => {
if (node.path === path) return { ...node, expanded: !node.expanded };
if (node.children) return { ...node, children: toggleNodeExpand(node.children, path) };
if (node.path === path)
return { ...node, expanded: !node.expanded };
if (node.children)
return { ...node, children: toggleNodeExpand(node.children, path) };
return node;
});
};
const toggleSelect = (path: string) => {
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(path)) newSet.delete(path);
else newSet.add(path);
if (newSet.has(path))
newSet.delete(path);
else
newSet.add(path);
return newSet;
});
};
const toggleFolderSelect = (node: FileNode) => {
const folderFiles = getAllFilesFlat([node]);
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (allSelected) folderFiles.forEach((f) => newSet.delete(f.path));
else folderFiles.forEach((f) => newSet.add(f.path));
if (allSelected)
folderFiles.forEach((f) => newSet.delete(f.path));
else
folderFiles.forEach((f) => newSet.add(f.path));
return newSet;
});
};
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
const folderFiles = getAllFilesFlat([node]);
if (folderFiles.length === 0) return false;
if (folderFiles.length === 0)
return false;
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
if (selectedCount === 0) return false;
if (selectedCount === folderFiles.length) return true;
if (selectedCount === 0)
return false;
if (selectedCount === folderFiles.length)
return true;
return "indeterminate";
};
const selectAll = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
const deselectAll = () => setSelectedFiles(new Set());
const resetToDefault = () => { setFormatPreset(DEFAULT_PRESET); setCustomFormat(DEFAULT_CUSTOM_FORMAT); setShowResetConfirm(false); };
const handlePreview = async (isPreviewOnly: boolean) => {
if (selectedFiles.size === 0) { toast.error("No files selected"); return; }
if (selectedFiles.size === 0) {
toast.error("No files selected");
return;
}
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
if (hasM4A) {
const installed = await IsFFprobeInstalled();
if (!installed) { setShowFFprobeDialog(true); return; }
if (!installed) {
setShowFFprobeDialog(true);
return;
}
}
try {
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
setPreviewData(result);
setPreviewOnly(isPreviewOnly);
setShowPreview(true);
} catch (err) {
}
catch (err) {
toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
if (filePath.toLowerCase().endsWith(".m4a")) {
const installed = await IsFFprobeInstalled();
if (!installed) { setShowFFprobeDialog(true); return; }
if (!installed) {
setShowFFprobeDialog(true);
return;
}
}
setMetadataFile(filePath);
setLoadingMetadata(true);
@@ -332,27 +312,33 @@ export function FileManagerPage() {
const metadata = await ReadFileMetadata(filePath);
setMetadataInfo(metadata as FileMetadata);
setShowMetadata(true);
} catch (err) {
}
catch (err) {
toast.error("Failed to read metadata", { description: err instanceof Error ? err.message : "Unknown error" });
setMetadataInfo(null);
} finally {
}
finally {
setLoadingMetadata(false);
}
};
const handleInstallFFprobe = async () => {
setInstallingFFprobe(true);
try {
const result = await DownloadFFmpeg();
if (result.success) { toast.success("FFprobe installed successfully"); setShowFFprobeDialog(false); }
else toast.error("Failed to install FFprobe", { description: result.error || result.message });
} catch (err) {
if (result.success) {
toast.success("FFprobe installed successfully");
setShowFFprobeDialog(false);
}
else
toast.error("Failed to install FFprobe", { description: result.error || result.message });
}
catch (err) {
toast.error("Failed to install FFprobe", { description: err instanceof Error ? err.message : "Unknown error" });
} finally {
}
finally {
setInstallingFFprobe(false);
}
};
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setLyricsFile(filePath);
@@ -361,11 +347,11 @@ export function FileManagerPage() {
const content = await ReadTextFile(filePath);
setLyricsContent(content);
setShowLyricsPreview(true);
} catch (err) {
}
catch (err) {
toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowCover = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(filePath);
@@ -373,24 +359,60 @@ export function FileManagerPage() {
const data = await ReadImageAsBase64(filePath);
setCoverData(data);
setShowCoverPreview(true);
} catch (err) {
}
catch (err) {
toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const getPlainLyrics = (content: string) => {
return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim();
};
const formatTimestamp = (timestamp: string): string => {
const match = timestamp.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/);
if (!match)
return timestamp;
const minutes = parseInt(match[1], 10);
const seconds = match[2];
return `${minutes}:${seconds}`;
};
const renderSyncedLyrics = (content: string) => {
if (!content)
return <div className="text-sm text-muted-foreground">No lyrics content</div>;
const lines = content.split('\n');
return lines.map((line, index) => {
if (line.match(/^\[(ti|ar|al|by|length|offset):/i))
return null;
const match = line.match(/^(\[[\d:.]+\])(.*)$/);
if (match) {
const timestamp = match[1];
const text = match[2].trim();
if (!text)
return null;
return (<div key={index} className="flex items-center gap-2 py-1">
<Badge variant="secondary" className="font-mono text-xs shrink-0">
{formatTimestamp(timestamp)}
</Badge>
<span className="text-sm">{text}</span>
</div>);
}
if (!line.trim())
return null;
return (<div key={index} className="py-1">
<span className="text-sm">{line}</span>
</div>);
}).filter(item => item !== null);
};
const handleCopyLyrics = async () => {
try {
const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent);
await navigator.clipboard.writeText(textToCopy);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 500);
} catch { toast.error("Failed to copy lyrics"); }
}
catch {
toast.error("Failed to copy lyrics");
}
};
const handleManualRename = (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileName = filePath.split(/[/\\]/).pop() || "";
@@ -399,73 +421,65 @@ export function FileManagerPage() {
setManualRenameName(nameWithoutExt);
setShowManualRename(true);
};
const handleConfirmManualRename = async () => {
if (!manualRenameFile || !manualRenameName.trim()) return;
if (!manualRenameFile || !manualRenameName.trim())
return;
setManualRenaming(true);
try {
await RenameFileTo(manualRenameFile, manualRenameName.trim());
toast.success("File renamed successfully");
setShowManualRename(false);
loadFiles();
} catch (err) {
}
catch (err) {
toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" });
} finally {
}
finally {
setManualRenaming(false);
}
};
const handleRename = async () => {
if (selectedFiles.size === 0) return;
if (selectedFiles.size === 0)
return;
setRenaming(true);
try {
const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat);
const successCount = result.filter((r: backend.RenameResult) => r.success).length;
const failCount = result.filter((r: backend.RenameResult) => !r.success).length;
if (successCount > 0) toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` });
else toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` });
if (successCount > 0)
toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` });
else
toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` });
setShowPreview(false);
setSelectedFiles(new Set());
loadFiles();
} catch (err) {
}
catch (err) {
toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" });
} finally {
}
finally {
setRenaming(false);
}
};
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (
<div key={node.path}>
<div
className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}
>
{node.is_dir ? (
<>
<Checkbox
checked={isFolderSelected(node) === true}
ref={(el) => { if (el) (el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked"; }}
onCheckedChange={() => toggleFolderSelect(node)}
onClick={(e) => e.stopPropagation()}
className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"
/>
return nodes.map((node) => (<div key={node.path}>
<div className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`} style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}>
{node.is_dir ? (<>
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
if (el)
(el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked";
}} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>
) : (
<>
</>) : (<>
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/>
<FileMusic className="h-4 w-4 text-primary shrink-0"/>
</>
)}
</>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (
<>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
@@ -475,36 +489,23 @@ export function FileManagerPage() {
</TooltipTrigger>
<TooltipContent>View Metadata</TooltipContent>
</Tooltip>
</>
)}
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
</div>
));
</div>));
};
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (
<div key={node.path}>
<div
className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}
>
{node.is_dir ? (
<>
return nodes.map((node) => (<div key={node.path}>
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}>
{node.is_dir ? (<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>
) : (
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
)}
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (
<>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
@@ -514,36 +515,23 @@ export function FileManagerPage() {
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>
)}
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
</div>
));
</div>));
};
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (
<div key={node.path}>
<div
className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}
>
{node.is_dir ? (
<>
return nodes.map((node) => (<div key={node.path}>
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}>
{node.is_dir ? (<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>
) : (
<Image className="h-4 w-4 text-green-500 shrink-0" />
)}
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (
<>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
@@ -553,23 +541,18 @@ export function FileManagerPage() {
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>
)}
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
</div>
));
</div>));
};
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
return (
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">File Manager</h1>
</div>
{/* Path Selection */}
<div className="flex items-center gap-2 shrink-0">
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
<Button onClick={handleSelectFolder}>
@@ -582,7 +565,7 @@ export function FileManagerPage() {
</Button>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
<FileMusic className="h-4 w-4"/>
@@ -598,9 +581,8 @@ export function FileManagerPage() {
</Button>
</div>
{/* Rename Format - Only for Track tab */}
{activeTab === "track" && (
<div className="space-y-2 shrink-0">
{activeTab === "track" && (<div className="space-y-2 shrink-0">
<div className="flex items-center gap-2">
<Label className="text-sm">Rename Format</Label>
<Tooltip>
@@ -616,14 +598,10 @@ export function FileManagerPage() {
<Select value={formatPreset} onValueChange={setFormatPreset}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent>
</Select>
{formatPreset === "custom" && (
<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1" />
)}
{formatPreset === "custom" && (<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
@@ -636,13 +614,11 @@ export function FileManagerPage() {
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>
</div>
)}
</div>)}
{/* File Tree */}
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
{activeTab === "track" && (
<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
{activeTab === "track" && (<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
{allSelected ? "Deselect All" : "Select All"}
@@ -659,25 +635,18 @@ export function FileManagerPage() {
Rename
</Button>
</div>
</div>
)}
</div>)}
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
{loading ? (
<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6" /></div>
) : filteredFiles.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{loading ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : filteredFiles.length === 0 ? (<div className="text-center py-8 text-muted-foreground">
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
</div>
) : (
activeTab === "track" ? renderTrackTree(filteredFiles) :
</div>) : (activeTab === "track" ? renderTrackTree(filteredFiles) :
activeTab === "lyric" ? renderLyricTree(filteredFiles) :
renderCoverTree(filteredFiles)
)}
renderCoverTree(filteredFiles))}
</div>
</div>
{/* Reset Confirmation Dialog */}
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
@@ -691,7 +660,7 @@ export function FileManagerPage() {
</DialogContent>
</Dialog>
{/* Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
@@ -699,41 +668,32 @@ export function FileManagerPage() {
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 py-4">
{previewData.map((item, index) => (
<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
<div className="text-sm">
<div className="text-muted-foreground break-all">{item.old_name}</div>
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1"> {item.new_name}</div>}
</div>
</div>
))}
</div>))}
</div>
<DialogFooter>
{previewOnly ? (
<Button onClick={() => setShowPreview(false)}>Close</Button>
) : (
<>
{previewOnly ? (<Button onClick={() => setShowPreview(false)}>Close</Button>) : (<>
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
<Button onClick={handleRename} disabled={renaming}>
{renaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
</Button>
</>
)}
</>)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Metadata Dialog */}
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>File Metadata</DialogTitle>
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
{loadingMetadata ? (
<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6" /></div>
) : metadataInfo ? (
<div className="space-y-3 py-2">
{loadingMetadata ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : metadataInfo ? (<div className="space-y-3 py-2">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
@@ -741,15 +701,12 @@ export function FileManagerPage() {
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
</div>
) : (
<div className="text-center py-4 text-muted-foreground">No metadata available</div>
)}
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
{/* FFprobe Install Dialog */}
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
@@ -765,7 +722,7 @@ export function FileManagerPage() {
</DialogContent>
</Dialog>
{/* Lyrics Preview Dialog */}
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
@@ -777,9 +734,11 @@ export function FileManagerPage() {
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
</div>
<div className="flex-1 overflow-y-auto py-4">
<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
{lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent) || "No lyrics content"}
</pre>
{lyricsTab === "synced" ? (<div className="bg-muted/30 p-4 rounded-lg space-y-0">
{renderSyncedLyrics(lyricsContent)}
</div>) : (<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
{getPlainLyrics(lyricsContent) || "No lyrics content"}
</pre>)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
@@ -791,7 +750,7 @@ export function FileManagerPage() {
</DialogContent>
</Dialog>
{/* Cover Preview Dialog */}
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
<DialogContent className="max-w-lg [&>button]:hidden">
<DialogHeader>
@@ -805,7 +764,7 @@ export function FileManagerPage() {
</DialogContent>
</Dialog>
{/* Manual Rename Dialog */}
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
<DialogContent className="max-w-2xl [&>button]:hidden">
<DialogHeader>
@@ -815,7 +774,10 @@ export function FileManagerPage() {
<div className="py-4">
<Label htmlFor="newName" className="text-sm">New Name</Label>
<div className="flex items-center gap-2 mt-2">
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => { if (e.key === "Enter" && !manualRenaming) handleConfirmManualRename(); }} />
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => {
if (e.key === "Enter" && !manualRenaming)
handleConfirmManualRename();
}}/>
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
</div>
</div>
@@ -827,6 +789,5 @@ export function FileManagerPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
</div>);
}
+10 -34
View File
@@ -1,66 +1,42 @@
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/relative-time";
interface HeaderProps {
version: string;
hasUpdate: boolean;
releaseDate?: string | null;
}
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return (
<div className="relative">
return (<div className="relative">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3">
<img
src="/icon.svg"
alt="SpotiFLAC"
className="w-12 h-12 cursor-pointer"
onClick={() => window.location.reload()}
/>
<h1
className="text-4xl font-bold cursor-pointer"
onClick={() => window.location.reload()}
>
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
SpotiFLAC
</h1>
<div className="relative">
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" asChild>
<button
type="button"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
<button type="button" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")} className="cursor-pointer hover:opacity-80 transition-opacity">
v{version}
</button>
</Badge>
</TooltipTrigger>
{hasUpdate && releaseDate && (
<TooltipContent>
{hasUpdate && releaseDate && (<TooltipContent>
<p>{formatRelativeTime(releaseDate)}</p>
</TooltipContent>
)}
</TooltipContent>)}
</Tooltip>
{hasUpdate && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
{hasUpdate && (<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
)}
</span>)}
</div>
</div>
<p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required.
</p>
</div>
</div>
);
</div>);
}
+12 -16
View File
@@ -1,22 +1,18 @@
// Platform Icons for streaming services
export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
export const TidalIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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>
);
export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
</svg>);
export const QobuzIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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>
);
export const AmazonIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
</svg>);
export const AmazonIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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>
);
</svg>);
+42 -158
View File
@@ -7,7 +7,6 @@ import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface PlaylistInfoProps {
playlistInfo: {
owner: {
@@ -21,6 +20,8 @@ interface PlaylistInfoProps {
followers: {
total: number;
};
cover?: string;
description?: string;
};
trackList: TrackMetadata[];
searchQuery: string;
@@ -33,18 +34,18 @@ interface PlaylistInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
@@ -55,7 +56,7 @@ interface PlaylistInfoProps {
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
@@ -66,79 +67,37 @@ interface PlaylistInfoProps {
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
onAlbumClick: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick: (track: TrackMetadata) => void;
}
export function PlaylistInfo({
playlistInfo,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
isBulkDownloadingLyrics,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllLyrics,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
onAlbumClick,
onArtistClick,
onTrackClick,
}: PlaylistInfoProps) {
return (
<div className="space-y-6">
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: PlaylistInfoProps) {
return (<div className="space-y-6">
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{playlistInfo.owner.images && (
<img
src={playlistInfo.owner.images}
alt={playlistInfo.owner.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Playlist</p>
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
{playlistInfo.description && (<p className="text-sm text-muted-foreground">{playlistInfo.description}</p>)}
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2">
{playlistInfo.owner.images && (<img src={playlistInfo.owner.images} alt={playlistInfo.owner.display_name} className="w-5 h-5 rounded-full object-cover"/>)}
<span className="font-medium">{playlistInfo.owner.display_name}</span>
</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>
@@ -146,121 +105,46 @@ export function PlaylistInfo({
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</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})
</Button>
)}
{onDownloadAllLyrics && (
<Tooltip>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllLyrics}
variant="outline"
disabled={isBulkDownloadingLyrics}
>
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>
)}
{onDownloadAllCovers && (
<Tooltip>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
variant="outline"
disabled={isBulkDownloadingCovers}
>
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>
)}
</Button>)}
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={false}
folderName={playlistInfo.owner.name}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick}
onTrackClick={onTrackClick}
/>
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={playlistInfo.owner.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>
</div>
);
</div>);
}
+9 -33
View File
@@ -1,45 +1,20 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Search, ArrowUpDown, XCircle } from "lucide-react";
interface SearchAndSortProps {
searchQuery: string;
sortBy: string;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
}
export function SearchAndSort({
searchQuery,
sortBy,
onSearchChange,
onSortChange,
}: SearchAndSortProps) {
return (
<div className="flex gap-2">
export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChange, }: SearchAndSortProps) {
return (<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 pr-8"
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onSearchChange("")}
>
<Input placeholder="Search tracks..." value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} className="pl-10 pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onSearchChange("")}>
<XCircle className="h-4 w-4"/>
</button>
)}
</button>)}
</div>
<Select value={sortBy} onValueChange={onSortChange}>
<SelectTrigger className="w-[200px] gap-1.5">
@@ -54,10 +29,11 @@ export function SearchAndSort({
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
<SelectItem value="plays-asc">Plays (Low)</SelectItem>
<SelectItem value="plays-desc">Plays (High)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
</SelectContent>
</Select>
</div>
);
</div>);
}
+111 -289
View File
@@ -3,23 +3,16 @@ import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } from "@/components/FetchHistory";
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { cn } from "@/lib/utils";
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
const MAX_RECENT_SEARCHES = 8;
const SEARCH_LIMIT = 50;
interface SearchBarProps {
url: string;
loading: boolean;
@@ -33,20 +26,7 @@ interface SearchBarProps {
searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void;
}
export function SearchBar({
url,
loading,
onUrlChange,
onFetch,
onFetchUrl,
history,
onHistorySelect,
onHistoryRemove,
hasResult,
searchMode,
onSearchModeChange,
}: SearchBarProps) {
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
@@ -61,62 +41,55 @@ export function SearchBar({
playlists: false,
});
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load recent searches from localStorage
useEffect(() => {
try {
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
if (saved) {
setRecentSearches(JSON.parse(saved));
}
} catch (error) {
}
catch (error) {
console.error("Failed to load recent searches:", error);
}
}, []);
const saveRecentSearch = (query: string) => {
const trimmed = query.trim();
if (!trimmed) return;
if (!trimmed)
return;
setRecentSearches((prev) => {
const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
} catch (error) {
}
catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
const removeRecentSearch = (query: string) => {
setRecentSearches((prev) => {
const updated = prev.filter((s) => s !== query);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
} catch (error) {
}
catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
// Debounced search - only search if query changed
useEffect(() => {
if (!searchMode || !searchQuery.trim()) {
return;
}
// Don't search again if query is the same
if (searchQuery.trim() === lastSearchedQuery) {
return;
}
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
@@ -124,47 +97,45 @@ export function SearchBar({
setSearchResults(results);
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
// Check if there might be more results
setHasMore({
tracks: results.tracks.length === SEARCH_LIMIT,
albums: results.albums.length === SEARCH_LIMIT,
artists: results.artists.length === SEARCH_LIMIT,
playlists: results.playlists.length === SEARCH_LIMIT,
});
// Auto-select first tab with results
if (results.tracks.length > 0) setActiveTab("tracks");
else if (results.albums.length > 0) setActiveTab("albums");
else if (results.artists.length > 0) setActiveTab("artists");
else if (results.playlists.length > 0) setActiveTab("playlists");
} catch (error) {
if (results.tracks.length > 0)
setActiveTab("tracks");
else if (results.albums.length > 0)
setActiveTab("albums");
else if (results.artists.length > 0)
setActiveTab("artists");
else if (results.playlists.length > 0)
setActiveTab("playlists");
}
catch (error) {
console.error("Search failed:", error);
setSearchResults(null);
} finally {
}
finally {
setIsSearching(false);
}
}, 400);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, searchMode, lastSearchedQuery]);
const handleLoadMore = async () => {
if (!searchResults || !lastSearchedQuery || isLoadingMore) return;
if (!searchResults || !lastSearchedQuery || isLoadingMore)
return;
const typeMap: Record<ResultTab, string> = {
tracks: "track",
albums: "album",
artists: "artist",
playlists: "playlist",
};
const currentCount = getTabCount(activeTab);
setIsLoadingMore(true);
try {
const moreResults = await SearchSpotifyByType({
@@ -173,11 +144,10 @@ export function SearchBar({
limit: SEARCH_LIMIT,
offset: currentCount,
});
if (moreResults.length > 0) {
setSearchResults((prev) => {
if (!prev) return prev;
// Create new SearchResponse with updated array for the active tab
if (!prev)
return prev;
const updated = new backend.SearchResponse({
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
@@ -187,39 +157,34 @@ export function SearchBar({
return updated;
});
}
// Update hasMore for this tab
setHasMore((prev) => ({
...prev,
[activeTab]: moreResults.length === SEARCH_LIMIT,
}));
} catch (error) {
}
catch (error) {
console.error("Load more failed:", error);
} finally {
}
finally {
setIsLoadingMore(false);
}
};
const handleResultClick = (externalUrl: string) => {
onSearchModeChange(false);
onFetchUrl(externalUrl);
};
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const hasAnyResults = searchResults && (
searchResults.tracks.length > 0 ||
const hasAnyResults = searchResults && (searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0
);
searchResults.playlists.length > 0);
const getTabCount = (tab: ResultTab): number => {
if (!searchResults) return 0;
if (!searchResults)
return 0;
switch (tab) {
case "tracks": return searchResults.tracks.length;
case "albums": return searchResults.albums.length;
@@ -227,43 +192,29 @@ export function SearchBar({
case "playlists": return searchResults.playlists.length;
}
};
const tabs: { key: ResultTab; label: string }[] = [
const tabs: {
key: ResultTab;
label: string;
}[] = [
{ key: "tracks", label: "Tracks" },
{ key: "albums", label: "Albums" },
{ key: "artists", label: "Artists" },
{ key: "playlists", label: "Playlists" },
];
return (
<div className="space-y-4">
return (<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
{/* Mode Toggle */}
<div className="flex items-center bg-muted rounded-md p-1">
<button
type="button"
onClick={() => onSearchModeChange(false)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
!searchMode
<button type="button" onClick={() => onSearchModeChange(false)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", !searchMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
: "text-muted-foreground hover:text-foreground")}>
<Link className="h-3.5 w-3.5"/>
URL
</button>
<button
type="button"
onClick={() => onSearchModeChange(true)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
searchMode
<button type="button" onClick={() => onSearchModeChange(true)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", searchMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
: "text-muted-foreground hover:text-foreground")}>
<Search className="h-3.5 w-3.5"/>
Search
</button>
@@ -274,176 +225,94 @@ export function SearchBar({
<Info className="h-4 w-4 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="right">
{!searchMode ? (
<>
{!searchMode ? (<>
<p>Supports track, album, playlist, and artist URLs</p>
<p className="mt-1">Note: Playlist must be public (not private)</p>
</>
) : (
<p>Search for tracks, albums, artists, or playlists</p>
)}
</>) : (<p>Search for tracks, albums, artists, or playlists</p>)}
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
{!searchMode ? (
<>
<InputWithContext
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onUrlChange("")}
>
{!searchMode ? (<>
<InputWithContext id="spotify-url" placeholder="https://open.spotify.com/..." value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
<XCircle className="h-4 w-4"/>
</button>
)}
</>
) : (
<>
<InputWithContext
id="spotify-search"
placeholder="Search tracks, albums, artists..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-8"
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => {
</button>)}
</>) : (<>
<InputWithContext id="spotify-search" placeholder="Search tracks, albums, artists..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
setSearchQuery("");
setSearchResults(null);
setLastSearchedQuery("");
}}
>
}}>
<XCircle className="h-4 w-4"/>
</button>
)}
</>
)}
</button>)}
</>)}
</div>
{!searchMode && (
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
{!searchMode && (<Button onClick={onFetch} disabled={loading}>
{loading ? (<>
<Spinner />
Fetching...
</>
) : (
<>
</>) : (<>
<CloudDownload className="h-4 w-4"/>
Fetch
</>
)}
</Button>
)}
</>)}
</Button>)}
</div>
</div>
{!searchMode && !hasResult && (
<FetchHistory
history={history}
onSelect={onHistorySelect}
onRemove={onHistoryRemove}
/>
)}
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
{/* Search Results with Tabs */}
{searchMode && (
<div className="space-y-4">
{/* Recent Searches - show when no query or no results yet */}
{!searchQuery && !searchResults && recentSearches.length > 0 && (
<div className="space-y-2">
{searchMode && (<div className="space-y-4">
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2">
{recentSearches.map((query) => (
<div
key={query}
className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors"
onClick={() => setSearchQuery(query)}
>
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
<span>{query}</span>
<button
type="button"
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
onClick={(e) => {
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
e.stopPropagation();
removeRecentSearch(query);
}}
>
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
</div>))}
</div>
))}
</div>
</div>
)}
</div>)}
{isSearching && (
<div className="flex items-center justify-center py-8">
{isSearching && (<div className="flex items-center justify-center py-8">
<Spinner />
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>
)}
</div>)}
{!isSearching && searchQuery && !hasAnyResults && (
<div className="text-center py-8 text-muted-foreground">
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
No results found for "{searchQuery}"
</div>
)}
</div>)}
{!isSearching && hasAnyResults && (<>
{!isSearching && hasAnyResults && (
<>
{/* Tabs */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
const count = getTabCount(tab.key);
if (count === 0) return null;
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px",
activeTab === tab.key
if (count === 0)
return null;
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
: "border-transparent text-muted-foreground hover:text-foreground")}>
{tab.label} ({count})
</button>
);
</button>);
})}
</div>
{/* Tab Content */}
<div className="grid gap-2">
{/* Tracks */}
{activeTab === "tracks" && searchResults?.tracks.map((track) => (
<button
key={track.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(track.external_urls)}
>
{track.images ? (
<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
{activeTab === "tracks" && searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.name}</p>
<p className="text-sm text-muted-foreground truncate">{track.artists}</p>
@@ -451,101 +320,54 @@ export function SearchBar({
<span className="text-sm text-muted-foreground shrink-0">
{formatDuration(track.duration_ms || 0)}
</span>
</button>
))}
</button>))}
{/* Albums */}
{activeTab === "albums" && searchResults?.albums.map((album) => (
<button
key={album.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(album.external_urls)}
>
{album.images ? (
<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
{activeTab === "albums" && searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{album.name}</p>
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{album.total_tracks} tracks
{album.release_date || ""}
</span>
</button>
))}
</button>))}
{/* Artists */}
{activeTab === "artists" && searchResults?.artists.map((artist) => (
<button
key={artist.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(artist.external_urls)}
>
{artist.images ? (
<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded-full bg-muted shrink-0" />
)}
{activeTab === "artists" && searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{artist.name}</p>
<p className="text-sm text-muted-foreground">Artist</p>
</div>
</button>
))}
</button>))}
{/* Playlists */}
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (
<button
key={playlist.id}
type="button"
className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors"
onClick={() => handleResultClick(playlist.external_urls)}
>
{playlist.images ? (
<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0" />
) : (
<div className="w-12 h-12 rounded bg-muted shrink-0" />
)}
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{playlist.name}</p>
<p className="text-sm text-muted-foreground truncate">
{playlist.owner} {playlist.total_tracks} tracks
{playlist.owner || ""}
</p>
</div>
</button>
))}
</button>))}
</div>
{/* Load More Button */}
{hasMore[activeTab] && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? (<>
<Spinner />
Loading...
</>
) : (
<>
</>) : (<>
<ChevronDown className="h-4 w-4"/>
Load More
</>
)}
</>)}
</Button>
</div>
)}
</>
)}
</div>
)}
</div>
);
</div>)}
</>)}
</div>)}
</div>);
}
+172 -172
View File
@@ -1,63 +1,63 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
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 { 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";
// Service Icons
const TidalIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
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>
);
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
</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>
);
const AmazonIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
</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>
);
export function SettingsPage() {
</svg>);
interface SettingsPageProps {
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 [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") {
@@ -65,11 +65,9 @@ export function SettingsPage() {
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [savedSettings.themeMode, savedSettings.theme]);
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
@@ -78,26 +76,23 @@ export function SettingsPage() {
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);
// Save to localStorage so it persists on reload
saveSettings(settingsWithDefaults);
await saveSettings(settingsWithDefaults);
}
};
loadDefaults();
}, []);
const handleSave = () => {
saveSettings(tempSettings);
const handleSave = async () => {
await saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
onUnsavedChangesChange?.(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
@@ -108,36 +103,74 @@ export function SettingsPage() {
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) {
}
catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
return (
<div className="space-y-6">
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 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">
{/* Left Column */}
<div className="space-y-4">
{/* Download Path */}
<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"
/>
<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
@@ -145,13 +178,10 @@ export function SettingsPage() {
</div>
</div>
{/* 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 }))}
>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
@@ -163,75 +193,55 @@ export function SettingsPage() {
</Select>
</div>
{/* Accent */}
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select
value={tempSettings.theme}
onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}
>
<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}>
{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={{
<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>))}
</SelectContent>
</Select>
</div>
{/* Font */}
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select
value={tempSettings.fontFamily}
onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}
>
<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}>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
</SelectItem>
))}
</SelectItem>))}
</SelectContent>
</Select>
</div>
{/* Sound Effects */}
<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 }))}
/>
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))} />
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
{/* Source Selection */}
<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 }))}
>
<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>
@@ -248,12 +258,8 @@ export function SettingsPage() {
</SelectItem>
</SelectContent>
</Select>
{/* Quality dropdown for Tidal */}
{tempSettings.downloader === "tidal" && (
<Select
value={tempSettings.tidalQuality}
onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}
>
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
@@ -261,50 +267,41 @@ export function SettingsPage() {
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
</SelectContent>
</Select>
)}
{/* Quality dropdown for Qobuz */}
{tempSettings.downloader === "qobuz" && (
<Select
value={tempSettings.qobuzQuality}
onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}
>
</Select>)}
{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>
<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>
<SelectItem value="7">FLAC 24-bit (Studio Quality)</SelectItem>
</SelectContent>
</Select>
</Select>)}
{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>
{/* Embed Lyrics & Embed Max Quality Cover */}
<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 }))}
/>
<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 }))}
/>
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))} />
</div>
</div>
<div className="border-t" />
{/* Folder Structure */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
@@ -318,45 +315,31 @@ export function SettingsPage() {
</Tooltip>
</div>
<div className="flex gap-2">
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
<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>
))}
{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"
/>
)}
{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">
{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>
)}
</p>)}
</div>
<div className="border-t" />
{/* Filename Format */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
@@ -370,45 +353,31 @@ export function SettingsPage() {
</Tooltip>
</div>
<div className="flex gap-2">
<Select
value={tempSettings.filenamePreset}
onValueChange={(value: FilenamePreset) => {
<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>
))}
{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"
/>
)}
{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">
{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>
)}
</p>)}
</div>
</div>
</div>
{/* Actions */}
<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" />
@@ -420,7 +389,7 @@ export function SettingsPage() {
</Button>
</div>
{/* Reset Confirmation Dialog */}
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
@@ -435,6 +404,37 @@ export function SettingsPage() {
</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>);
}
+25 -78
View File
@@ -1,39 +1,27 @@
import { FileMusic, FilePen } from "lucide-react";
import { HomeIcon } from "@/components/ui/home";
import { SettingsIcon } from "@/components/ui/settings";
import { ActivityIcon } from "@/components/ui/activity";
import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon } from "@/components/ui/file-music";
import { FilePenIcon } from "@/components/ui/file-pen";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks";
import { CoffeeIcon } from "@/components/ui/coffee";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return (
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1">
{/* Home */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "main" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("main")}
>
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
<HomeIcon size={20}/>
</Button>
</TooltipTrigger>
@@ -42,15 +30,10 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent>
</Tooltip>
{/* Settings */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "settings" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("settings")}
>
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
<SettingsIcon size={20}/>
</Button>
</TooltipTrigger>
@@ -59,15 +42,10 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent>
</Tooltip>
{/* Audio Analysis */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "audio-analysis" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("audio-analysis")}
>
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
<ActivityIcon size={20}/>
</Button>
</TooltipTrigger>
@@ -76,16 +54,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent>
</Tooltip>
{/* Audio Converter - using lucide icon (no animated version) */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "audio-converter" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("audio-converter")}
>
<FileMusic className="h-5 w-5" />
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
<FileMusicIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
@@ -93,16 +66,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent>
</Tooltip>
{/* File Manager - using lucide icon (no animated version) */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "file-manager" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("file-manager")}
>
<FilePen className="h-5 w-5" />
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
<FilePenIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
@@ -110,15 +78,10 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent>
</Tooltip>
{/* Debug */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === "debug" ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("debug")}
>
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
<TerminalIcon size={20}/>
</Button>
</TooltipTrigger>
@@ -128,31 +91,21 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</Tooltip>
</div>
{/* Bottom icons */}
<div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
>
<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}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://exyezed.cc/")}
>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://exyezed.cc/")}>
<BlocksIcon size={20}/>
</Button>
</TooltipTrigger>
@@ -162,20 +115,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support me on Ko-fi</p>
<p>Every coffee helps me keep going</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
</div>);
}
+54 -150
View File
@@ -1,184 +1,119 @@
import { useEffect, useRef } from "react";
import type { SpectrumData } from "@/types/api";
interface SpectrumVisualizationProps {
sampleRate: number;
bitsPerSample: number;
duration: number;
spectrumData?: SpectrumData;
}
export function SpectrumVisualization({
sampleRate,
bitsPerSample,
duration,
spectrumData,
}: SpectrumVisualizationProps) {
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!canvas)
return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!ctx)
return;
const width = canvas.width;
const height = canvas.height;
// Calculate margins for labels
const marginLeft = 70; // More space for Frequency label
const marginRight = 70; // Space for color bar
const marginTop = 30; // More space at top
const marginBottom = 65; // More space at bottom for Time label
const marginLeft = 70;
const marginRight = 70;
const marginTop = 30;
const marginBottom = 65;
const plotWidth = width - marginLeft - marginRight;
const plotHeight = height - marginTop - marginBottom;
// Black background
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
// Calculate Nyquist frequency
const nyquistFreq = sampleRate / 2;
if (spectrumData) {
drawRealSpectrum(
ctx,
marginLeft,
marginTop,
plotWidth,
plotHeight,
spectrumData
);
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
}
// Draw axes, labels, and color bar
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
}, [sampleRate, bitsPerSample, duration, spectrumData]);
const drawRealSpectrum = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
spectrum: SpectrumData
) => {
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
const timeSlices = spectrum.time_slices;
if (timeSlices.length === 0) return;
if (timeSlices.length === 0)
return;
const freqBins = timeSlices[0].magnitudes.length;
const nyquistFreq = spectrum.max_freq;
// Find min/max dB values
let minDB = 0;
let maxDB = -200;
timeSlices.forEach((slice) => {
slice.magnitudes.forEach((db) => {
if (db > maxDB) maxDB = db;
if (db < minDB && db > -200) minDB = db;
if (db > maxDB)
maxDB = db;
if (db < minDB && db > -200)
minDB = db;
});
});
// Clamp range for better visualization
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
minDB = Math.max(minDB, maxDB - 90);
const dbRange = maxDB - minDB;
const sliceWidth = Math.ceil(width / timeSlices.length);
for (let t = 0; t < timeSlices.length; t++) {
const slice = timeSlices[t];
const xPos = x + (t / timeSlices.length) * width;
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
const db = slice.magnitudes[f];
// Linear frequency scale
const freq = (f / freqBins) * nyquistFreq;
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
// Calculate bin height
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
const nextFreqRatio = nextFreq / nyquistFreq;
const nextYPos = y + height - (nextFreqRatio * height);
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
// Normalize intensity
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
}
}
};
// Vibrant color scheme like Spek - NGEJERENG!
const getSpekColor = (intensity: number): string => {
if (intensity < 0.08) {
// Black to deep blue
const t = intensity / 0.08;
return `rgb(0, 0, ${Math.floor(t * 80)})`;
} else if (intensity < 0.18) {
// Deep blue to bright blue
}
else if (intensity < 0.18) {
const t = (intensity - 0.08) / 0.10;
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
} else if (intensity < 0.28) {
// Blue to magenta/purple
}
else if (intensity < 0.28) {
const t = (intensity - 0.18) / 0.10;
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
} else if (intensity < 0.40) {
// Magenta to bright red
}
else if (intensity < 0.40) {
const t = (intensity - 0.28) / 0.12;
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
} else if (intensity < 0.52) {
// Red to orange-red
}
else if (intensity < 0.52) {
const t = (intensity - 0.40) / 0.12;
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
} else if (intensity < 0.65) {
// Orange-red to bright orange
}
else if (intensity < 0.65) {
const t = (intensity - 0.52) / 0.13;
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
} else if (intensity < 0.78) {
// Orange to yellow-orange
}
else if (intensity < 0.78) {
const t = (intensity - 0.65) / 0.13;
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
} else if (intensity < 0.90) {
// Yellow-orange to bright yellow
}
else if (intensity < 0.90) {
const t = (intensity - 0.78) / 0.12;
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
} else {
// Yellow to white (hottest)
}
else {
const t = (intensity - 0.90) / 0.10;
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
}
};
const drawAxesAndLabels = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
nyquistFreq: number,
duration: number,
sampleRate: number
) => {
// Frequency labels on Y-axis
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
// Generate frequency labels based on Nyquist
const freqLabels = generateFreqLabels(nyquistFreq);
freqLabels.forEach(freq => {
if (freq <= nyquistFreq) {
const freqRatio = freq / nyquistFreq;
@@ -187,103 +122,72 @@ export function SpectrumVisualization({
ctx.fillText(label, x - 8, yPos);
}
});
// "0" at bottom
ctx.fillText("0", x - 8, y + height);
// Time labels on X-axis
ctx.textAlign = "center";
ctx.textBaseline = "top";
const timeStep = getTimeStep(duration);
for (let t = 0; t <= duration; t += timeStep) {
const xPos = x + (t / duration) * width;
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
}
// Axis titles
ctx.fillStyle = "#FFFFFF";
ctx.font = "13px Arial";
// Y-axis title: "Frequency (Hz)"
ctx.save();
ctx.translate(12, y + height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Frequency (Hz)", 0, 0);
ctx.restore();
// X-axis title: "Time (seconds)"
ctx.textAlign = "center";
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
// Sample rate info in top right
ctx.textAlign = "right";
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
};
const generateFreqLabels = (nyquistFreq: number): number[] => {
if (nyquistFreq <= 24000) {
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
} else if (nyquistFreq <= 48000) {
}
else if (nyquistFreq <= 48000) {
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
} else if (nyquistFreq <= 96000) {
}
else if (nyquistFreq <= 96000) {
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
} else {
}
else {
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
}
};
const getTimeStep = (duration: number): number => {
// Always use 30s intervals like the reference image
if (duration <= 60) return 15;
if (duration <= 120) return 30;
if (duration <= 300) return 30;
if (duration <= 600) return 60;
if (duration <= 60)
return 15;
if (duration <= 120)
return 30;
if (duration <= 300)
return 30;
if (duration <= 600)
return 60;
return 60;
};
const drawColorBar = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number
) => {
// Draw gradient color bar
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
for (let i = 0; i < height; i++) {
const intensity = 1 - (i / height); // Top is high, bottom is low
const intensity = 1 - (i / height);
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(x, y + i, width, 1);
}
// Border around color bar
ctx.strokeStyle = "#666666";
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
// Labels
ctx.fillStyle = "#FFFFFF";
ctx.font = "11px Arial";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText("High", x + width + 5, y + 10);
ctx.fillText("Low", x + width + 5, y + height - 10);
};
return (
<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
<canvas
ref={canvasRef}
width={1200}
height={600}
className="w-full h-auto"
style={{ imageRendering: "auto" }}
/>
</div>
);
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
</div>);
}
+7 -32
View File
@@ -1,55 +1,30 @@
import { X, Minus, Maximize } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
export function TitleBar() {
const handleMinimize = () => {
WindowMinimise();
};
const handleMaximize = () => {
WindowToggleMaximise();
};
const handleClose = () => {
Quit();
};
return (<>
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
return (
<>
{/* Draggable area */}
<div
className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm"
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
onDoubleClick={handleMaximize}
/>
{/* Window control buttons - Windows style, right side */}
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
<button
onClick={handleMinimize}
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Minimize"
>
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
<Minus className="w-3.5 h-3.5"/>
</button>
<button
onClick={handleMaximize}
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Maximize"
>
<button onClick={handleMaximize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Maximize">
<Maximize className="w-3.5 h-3.5"/>
</button>
<button
onClick={handleClose}
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Close"
>
<button onClick={handleClose} className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Close">
<X className="w-3.5 h-3.5"/>
</button>
</div>
</>
);
</>);
}
+75 -131
View File
@@ -1,17 +1,16 @@
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 { 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; release_date: string };
track: TrackMetadata & {
album_name: string;
release_date: string;
};
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
@@ -27,181 +26,126 @@ interface TrackInfoProps {
downloadedCover?: boolean;
failedCover?: boolean;
skippedCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onOpenFolder: () => void;
}
export function TrackInfo({
track,
isDownloading,
downloadingTrack,
isDownloaded,
isFailed,
isSkipped,
downloadingLyricsTrack,
downloadedLyrics,
failedLyrics,
skippedLyrics,
checkingAvailability,
availability,
downloadingCover,
downloadedCover,
failedCover,
skippedCover,
onDownload,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onOpenFolder,
}: TrackInfoProps) {
return (
<Card>
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);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const formatPlays = (plays: string) => {
const num = parseInt(plays, 10);
if (isNaN(num))
return plays;
return num.toLocaleString();
};
return (<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
<div className="shrink-0">
{track.images && (
<img
src={track.images}
alt={track.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
{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}
{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>
{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)}
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
</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>
{track.spotify_id && (<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 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>
</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 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>
</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 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">
{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>
)}
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>
)}
{isDownloaded && (
<Button onClick={onOpenFolder} variant="outline">
</Tooltip>)}
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>
)}
</div>
)}
</Button>)}
</div>)}
</div>
</div>
</CardContent>
</Card>
);
</Card>);
}
+200 -318
View File
@@ -1,23 +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 { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
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;
@@ -34,454 +23,347 @@ interface TrackListProps {
hideAlbumColumn?: boolean;
folderName?: string;
isArtistDiscography?: boolean;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onPageChange: (page: number) => void;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
onAlbumClick?: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function TrackList({
tracks,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
failedTracks,
skippedTracks,
downloadingTrack,
isDownloading,
currentPage,
itemsPerPage,
showCheckboxes = false,
hideAlbumColumn = false,
folderName,
isArtistDiscography = false,
downloadedLyrics,
failedLyrics,
skippedLyrics,
downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onPageChange,
onAlbumClick,
onArtistClick,
onTrackClick,
}: TrackListProps) {
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
let filteredTracks = tracks.filter((track) => {
if (!searchQuery) return true;
if (!searchQuery)
return true;
const query = searchQuery.toLowerCase();
return (
track.name.toLowerCase().includes(query) ||
return (track.name.toLowerCase().includes(query) ||
track.artists.toLowerCase().includes(query) ||
track.album_name.toLowerCase().includes(query)
);
track.album_name.toLowerCase().includes(query));
});
// Apply sorting
if (sortBy === "title-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
} else if (sortBy === "title-desc") {
}
else if (sortBy === "title-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
} else if (sortBy === "artist-asc") {
}
else if (sortBy === "artist-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
} else if (sortBy === "artist-desc") {
}
else if (sortBy === "artist-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
} else if (sortBy === "duration-asc") {
}
else if (sortBy === "duration-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
} else if (sortBy === "duration-desc") {
}
else if (sortBy === "duration-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
} else if (sortBy === "downloaded") {
}
else if (sortBy === "plays-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
if (isNaN(aPlays))
return 1;
if (isNaN(bPlays))
return -1;
return aPlays - bPlays;
});
}
else if (sortBy === "plays-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
if (isNaN(aPlays))
return 1;
if (isNaN(bPlays))
return -1;
return bPlays - aPlays;
});
}
else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
});
} else if (sortBy === "not-downloaded") {
}
else if (sortBy === "not-downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
});
}
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
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 &&
const allSelected = tracksWithIsrc.length > 0 &&
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className="space-y-4">
const formatPlays = (plays: string | undefined) => {
if (!plays)
return "";
const num = parseInt(plays, 10);
if (isNaN(num))
return plays;
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>
)}
{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">
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
Album
</th>
)}
</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>
)}
{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">
{startIndex + index + 1}
<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>)}
</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"
/>
)}
{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)}
>
{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}
</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 ? (
track.artists_data.map((artist, i, arr) => (
<span key={artist.id}>
{onArtistClick ? (
<span
className="cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})
}
>
{artist.name}
</span>
) : (
artist.name
)}
{i < arr.length - 1 && ", "}
</span>
))
) : onArtistClick && track.artist_id && track.artist_url ? (
<span
className="cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
{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({
id: artistData.id,
name: name,
external_urls: artistData.external_urls,
})}>
{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>) : (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({
{!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>
)}
</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>
{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)
}
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 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>
)}
{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>
</Tooltip>)}
{track.spotify_id && (<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 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>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
<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" />
)}
}} 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>
</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 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">
{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>
)}
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>
)}
</Tooltip>)}
</div>
</td>
</tr>
))}
</tr>))}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (
<Pagination>
{totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1) onPageChange(currentPage - 1);
}}
className={
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
if (currentPage > 1)
onPageChange(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</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"
>
}} isActive={currentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>
))}
</PaginationItem>)))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages) onPageChange(currentPage + 1);
}}
className={
currentPage === totalPages
if (currentPage < totalPages)
onPageChange(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
: "cursor-pointer"}/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
</Pagination>)}
</div>);
}
+14 -55
View File
@@ -1,21 +1,16 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ActivityIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
@@ -32,73 +27,37 @@ const PATH_VARIANTS: Variants = {
},
},
};
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
}
else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
}
else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
variants={PATH_VARIANTS}
animate={controls}
initial="normal"
/>
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>
);
}
);
</div>);
});
ActivityIcon.displayName = 'ActivityIcon';
export { ActivityIcon };
+16 -38
View File
@@ -1,46 +1,24 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", {
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "span";
return (<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props}/>);
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+14 -54
View File
@@ -1,92 +1,52 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface BlocksIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const VARIANTS: Variants = {
normal: { translateX: 0, translateY: 0 },
animate: { translateX: -4, translateY: 4 },
};
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
}
else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
}
else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
<motion.path
d="M14 3h7v7h-7z"
variants={VARIANTS}
animate={controls}
/>
<motion.path d="M14 3h7v7h-7z" variants={VARIANTS} animate={controls}/>
</svg>
</div>
);
}
);
</div>);
});
BlocksIcon.displayName = 'BlocksIcon';
export { BlocksIcon };
+18 -43
View File
@@ -1,60 +1,35 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", {
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
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: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
});
function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
const Comp = asChild ? Slot : "button";
return (<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props}/>);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+10 -78
View File
@@ -1,92 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
return (<div data-slot="card" className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} {...props}/>);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
return (<div data-slot="card-header" className={cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)} {...props}/>);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
return (<div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props}/>);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
return (<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
return (<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props}/>);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
return (<div data-slot="card-content" className={cn("px-6", className)} {...props}/>);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
return (<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props}/>);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, };
+10 -29
View File
@@ -1,32 +1,13 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (<CheckboxPrimitive.Root data-slot="checkbox" className={cn("peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none">
<CheckIcon className="size-3.5"/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
</CheckboxPrimitive.Root>);
}
export { Checkbox }
export { Checkbox };
+16 -68
View File
@@ -1,21 +1,16 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface CoffeeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
y: 0,
@@ -32,87 +27,40 @@ const PATH_VARIANTS: Variants = {
},
}),
};
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
}
else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
}
else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ overflow: 'visible' }}
>
<motion.path
d="M10 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0.2}
/>
<motion.path
d="M14 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0.4}
/>
<motion.path
d="M6 2v2"
animate={controls}
variants={PATH_VARIANTS}
custom={0}
/>
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}>
<motion.path d="M10 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.2}/>
<motion.path d="M14 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.4}/>
<motion.path d="M6 2v2" animate={controls} variants={PATH_VARIANTS} custom={0}/>
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
</svg>
</div>
);
}
);
</div>);
});
CoffeeIcon.displayName = 'CoffeeIcon';
export { CoffeeIcon };
+45 -220
View File
@@ -1,252 +1,77 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props}/>;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props}/>);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props}/>);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props}/>);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props}/>;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (<ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props}/>);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
return (<ContextMenuPrimitive.SubTrigger data-slot="context-menu-sub-trigger" data-inset={inset} className={cn("focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
{children}
<ChevronRightIcon className="ml-auto"/>
</ContextMenuPrimitive.SubTrigger>
)
</ContextMenuPrimitive.SubTrigger>);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (<ContextMenuPrimitive.SubContent data-slot="context-menu-sub-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", className)} {...props}/>);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content data-slot="context-menu-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", className)} {...props}/>
</ContextMenuPrimitive.Portal>);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
return (<ContextMenuPrimitive.Item data-slot="context-menu-item" data-inset={inset} data-variant={variant} className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}/>);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (<ContextMenuPrimitive.CheckboxItem data-slot="context-menu-checkbox-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
</ContextMenuPrimitive.CheckboxItem>);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (<ContextMenuPrimitive.RadioItem data-slot="context-menu-radio-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
</ContextMenuPrimitive.RadioItem>);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
className
)}
{...props}
/>
)
return (<ContextMenuPrimitive.Label data-slot="context-menu-label" data-inset={inset} className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8", className)} {...props}/>);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (<ContextMenuPrimitive.Separator data-slot="context-menu-separator" className={cn("bg-border -mx-1 my-1 h-px", className)} {...props}/>);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (<span data-slot="context-menu-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props}/>);
}
export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };
+29 -125
View File
@@ -1,143 +1,47 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props}/>;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}/>;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props}/>;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props}/>;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (<DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn("data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className)} {...props}/>);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
return (<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
className
)}
{...props}
>
<DialogPrimitive.Content data-slot="dialog-content" className={cn("bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200", className)} {...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
{showCloseButton && (<DialogPrimitive.Close data-slot="dialog-close" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Close>)}
</DialogPrimitive.Content>
</DialogPortal>
)
</DialogPortal>);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
return (<div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props}/>);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
return (<div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props}/>);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (<DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props}/>);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
+64
View File
@@ -0,0 +1,64 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FileMusicIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M8 20v-7l3 1.474" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.circle cx="6" cy="20" r="2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FileMusicIcon.displayName = 'FileMusicIcon';
export { FileMusicIcon };
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FilePenIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FilePenIcon.displayName = 'FilePenIcon';
export { FilePenIcon };
+15 -62
View File
@@ -1,21 +1,16 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
normal: {
opacity: 1,
@@ -34,7 +29,6 @@ const BODY_VARIANTS: Variants = {
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
@@ -60,16 +54,12 @@ const TAIL_VARIANTS: Variants = {
},
},
};
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const bodyControls = useAnimation();
const tailControls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: async () => {
bodyControls.start('animate');
@@ -82,68 +72,31 @@ const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(
},
};
});
const handleMouseEnter = useCallback(
async (e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('animate');
await tailControls.start('draw');
tailControls.start('wag');
} else {
}
else {
onMouseEnter?.(e);
}
},
[bodyControls, onMouseEnter, tailControls]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
}, [bodyControls, onMouseEnter, tailControls]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('normal');
tailControls.start('normal');
} else {
}
else {
onMouseLeave?.(e);
}
},
[bodyControls, tailControls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
variants={BODY_VARIANTS}
initial="normal"
animate={bodyControls}
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
/>
<motion.path
variants={TAIL_VARIANTS}
initial="normal"
animate={tailControls}
d="M9 18c-4.51 2-5-2-7-2"
/>
}, [bodyControls, tailControls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path variants={BODY_VARIANTS} initial="normal" animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
<motion.path variants={TAIL_VARIANTS} initial="normal" animate={tailControls} d="M9 18c-4.51 2-5-2-7-2"/>
</svg>
</div>
);
}
);
</div>);
});
GithubIcon.displayName = 'GithubIcon';
export { GithubIcon };
+14 -55
View File
@@ -1,26 +1,20 @@
'use client';
import type { Transition, Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface HomeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DEFAULT_TRANSITION: Transition = {
duration: 0.6,
opacity: { duration: 0.2 },
};
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
@@ -31,73 +25,38 @@ const PATH_VARIANTS: Variants = {
pathLength: [0, 1],
},
};
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
}
else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
}
else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<motion.path
d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"
variants={PATH_VARIANTS}
transition={DEFAULT_TRANSITION}
animate={controls}
/>
<motion.path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" variants={PATH_VARIANTS} transition={DEFAULT_TRANSITION} animate={controls}/>
</svg>
</div>
);
}
);
</div>);
});
HomeIcon.displayName = 'HomeIcon';
export { HomeIcon };
@@ -1,65 +1,45 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu";
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
export interface InputWithContextProps
extends React.InputHTMLAttributes<HTMLInputElement> {
export interface InputWithContextProps extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void;
}
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(
({ className, type, onValueChange, onChange, ...props }, ref) => {
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(({ className, type, onValueChange, onChange, ...props }, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [hasSelection, setHasSelection] = React.useState(false);
const [canPaste, setCanPaste] = React.useState(false);
// Combine refs
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
// Check selection state
const updateSelectionState = () => {
const input = inputRef.current;
if (!input) return;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
setHasSelection(start !== end);
};
// Check clipboard permission when user explicitly opens the context menu.
const checkClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
setCanPaste(text.length > 0);
} catch {
}
catch {
setCanPaste(false);
}
};
const handleCut = async () => {
const input = inputRef.current;
if (!input) return;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
const newValue = input.value.substring(0, start) + input.value.substring(end);
// Update value and trigger change
input.value = newValue;
input.setSelectionRange(start, start);
// Trigger React onChange
if (onChange) {
const event = {
target: input,
@@ -67,54 +47,45 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
} catch (err) {
}
catch (err) {
console.error("Failed to cut:", err);
}
}
};
const handleCopy = async () => {
const input = inputRef.current;
if (!input) return;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
input.focus();
} catch (err) {
}
catch (err) {
console.error("Failed to copy:", err);
}
}
};
const handlePaste = async () => {
const input = inputRef.current;
if (!input) return;
if (!input)
return;
try {
const text = await navigator.clipboard.readText();
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const newValue =
input.value.substring(0, start) + text + input.value.substring(end);
// Update value and trigger change
const newValue = input.value.substring(0, start) + text + input.value.substring(end);
input.value = newValue;
const newPosition = start + text.length;
input.setSelectionRange(newPosition, newPosition);
// Trigger React onChange
if (onChange) {
const event = {
target: input,
@@ -122,26 +93,24 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
await checkClipboard();
} catch (err) {
}
catch (err) {
console.error("Failed to paste:", err);
}
};
const handleSelectAll = () => {
const input = inputRef.current;
if (!input) return;
if (!input)
return;
input.select();
input.focus();
updateSelectionState();
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
@@ -150,67 +119,38 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
onValueChange(e.target.value);
}
};
return (
<ContextMenu
onOpenChange={(open) => {
return (<ContextMenu onOpenChange={(open) => {
if (open) {
checkClipboard();
}
}}
>
}}>
<ContextMenuTrigger asChild>
<Input
ref={inputRef}
type={type}
className={className}
onChange={handleInputChange}
onSelect={updateSelectionState}
onMouseUp={updateSelectionState}
onKeyUp={updateSelectionState}
{...props}
/>
<Input ref={inputRef} type={type} className={className} onChange={handleInputChange} onSelect={updateSelectionState} onMouseUp={updateSelectionState} onKeyUp={updateSelectionState} {...props}/>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
onSelect={handleCut}
disabled={!hasSelection || props.disabled || props.readOnly}
>
<ContextMenuItem onSelect={handleCut} disabled={!hasSelection || props.disabled || props.readOnly}>
<Scissors className="mr-2 h-4 w-4"/>
Cut
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={handleCopy}
disabled={!hasSelection || props.disabled}
>
<ContextMenuItem onSelect={handleCopy} disabled={!hasSelection || props.disabled}>
<Copy className="mr-2 h-4 w-4"/>
Copy
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={handlePaste}
disabled={!canPaste || props.disabled || props.readOnly}
>
<ContextMenuItem onSelect={handlePaste} disabled={!canPaste || props.disabled || props.readOnly}>
<Clipboard className="mr-2 h-4 w-4"/>
Paste
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={handleSelectAll}
disabled={!inputRef.current?.value || props.disabled}
>
<ContextMenuItem onSelect={handleSelectAll} disabled={!inputRef.current?.value || props.disabled}>
<Type className="mr-2 h-4 w-4"/>
Select All
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
);
</ContextMenu>);
});
InputWithContext.displayName = "InputWithContext";
export { InputWithContext };
+4 -19
View File
@@ -1,21 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
return (<input type={type} data-slot="input" className={cn("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className)} {...props}/>);
}
export { Input }
export { Input };
+7 -23
View File
@@ -1,24 +1,8 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (<LabelPrimitive.Root data-slot="label" className={cn("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className)} {...props}/>);
}
export { Label }
export { Label };
+23 -109
View File
@@ -1,127 +1,41 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
return (<nav role="navigation" aria-label="pagination" data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...props}/>);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return (<ul data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)} {...props}/>);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
return <li data-slot="pagination-item" {...props}/>;
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> & React.ComponentProps<"a">;
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
return (<a aria-current={isActive ? "page" : undefined} data-slot="pagination-link" data-active={isActive} className={cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}), className)} {...props}/>);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 px-2.5 sm:pl-2.5", className)} {...props}>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
</PaginationLink>);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 px-2.5 sm:pr-2.5", className)} {...props}>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
</PaginationLink>);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (<span aria-hidden data-slot="pagination-ellipsis" className={cn("flex size-9 items-center justify-center", className)} {...props}>
<MoreHorizontalIcon className="size-4"/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
</span>);
}
export { Pagination, PaginationContent, PaginationLink, PaginationItem, PaginationPrevious, PaginationNext, PaginationEllipsis, };
+9 -30
View File
@@ -1,31 +1,10 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (<ProgressPrimitive.Root data-slot="progress" className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)} {...props}>
<ProgressPrimitive.Indicator data-slot="progress-indicator" className="bg-primary h-full w-full flex-1 transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }}/>
</ProgressPrimitive.Root>);
}
export { Progress }
export { Progress };
+35 -157
View File
@@ -1,185 +1,63 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props}/>;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props}/>;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props}/>;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
function SelectTrigger({ className, size = "default", children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
return (<SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn("border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50"/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
</SelectPrimitive.Trigger>);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
function SelectContent({ className, children, position = "popper", align = "center", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (<SelectPrimitive.Portal>
<SelectPrimitive.Content data-slot="select-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className)} position={position} align={align} {...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
)}
>
<SelectPrimitive.Viewport className={cn("p-1", position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
</SelectPrimitive.Portal>);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
</SelectPrimitive.Item>);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (<SelectPrimitive.Separator data-slot="select-separator" className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props}/>);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (<SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronUpIcon className="size-4"/>
</SelectPrimitive.ScrollUpButton>
)
</SelectPrimitive.ScrollUpButton>);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (<SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronDownIcon className="size-4"/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
</SelectPrimitive.ScrollDownButton>);
}
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, };
+14 -52
View File
@@ -1,92 +1,54 @@
'use client';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface SettingsIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
}
else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
}
else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
transition={{ type: 'spring', stiffness: 50, damping: 10 }}
variants={{
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<motion.svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" transition={{ type: 'spring', stiffness: 50, damping: 10 }} variants={{
normal: {
rotate: 0,
},
animate: {
rotate: 180,
},
}}
animate={controls}
>
}} animate={controls}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</motion.svg>
</div>
);
}
);
</div>);
});
SettingsIcon.displayName = 'SettingsIcon';
export { SettingsIcon };
+10 -30
View File
@@ -1,47 +1,27 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, } from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
const { theme = "system" } = useTheme();
return (<Sonner theme={theme as ToasterProps["theme"]} className="toaster group" icons={{
success: <CircleCheckIcon className="size-4"/>,
info: <InfoIcon className="size-4"/>,
warning: <TriangleAlertIcon className="size-4"/>,
error: <OctagonXIcon className="size-4"/>,
loading: <Loader2Icon className="size-4 animate-spin"/>,
}}
toastOptions={{
}} toastOptions={{
classNames: {
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
},
}}
style={
{
}} style={{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
left: "calc(56px + 1rem)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
} as React.CSSProperties} {...props}/>);
};
export { Toaster };
+4 -13
View File
@@ -1,15 +1,6 @@
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
return (<Loader2 role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props}/>);
}
export { Spinner }
export { Spinner };
+9 -30
View File
@@ -1,31 +1,10 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background"
)}
/>
</SwitchPrimitive.Root>
)
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (<SwitchPrimitive.Root data-slot="switch" className={cn("peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
<SwitchPrimitive.Thumb data-slot="switch-thumb" className={cn("pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background")}/>
</SwitchPrimitive.Root>);
}
export { Switch }
export { Switch };
+14 -58
View File
@@ -1,21 +1,16 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface TerminalIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const LINE_VARIANTS: Variants = {
normal: { opacity: 1 },
animate: {
@@ -27,77 +22,38 @@ const LINE_VARIANTS: Variants = {
},
},
};
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
} else {
}
else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter]
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
} else {
}
else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave]
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 17 10 11 4 5"/>
<motion.line
x1="12"
x2="20"
y1="19"
y2="19"
variants={LINE_VARIANTS}
animate={controls}
initial="normal"
/>
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>
);
}
);
</div>);
});
TerminalIcon.displayName = 'TerminalIcon';
export { TerminalIcon };
+20 -71
View File
@@ -1,83 +1,32 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants> & {
spacing?: number;
}>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
});
function ToggleGroup({ className, variant, size, spacing = 0, children, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
return (<ToggleGroupPrimitive.Root data-slot="toggle-group" data-variant={variant} data-size={size} data-spacing={spacing} style={{ "--gap": spacing } as React.CSSProperties} className={cn("group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs", className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
</ToggleGroupPrimitive.Root>);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
function ToggleGroupItem({ className, children, variant, size, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (<ToggleGroupPrimitive.Item data-slot="toggle-group-item" data-variant={context.variant || variant} data-size={context.size || size} data-spacing={context.spacing} className={cn(toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
}), "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10", "data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l", className)} {...props}>
{children}
</ToggleGroupPrimitive.Item>
)
</ToggleGroupPrimitive.Item>);
}
export { ToggleGroup, ToggleGroupItem }
export { ToggleGroup, ToggleGroupItem };
+11 -32
View File
@@ -1,19 +1,13 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva("inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", {
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
@@ -25,23 +19,8 @@ const toggleVariants = cva(
variant: "default",
size: "default",
},
});
function Toggle({ className, variant, size, ...props }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (<TogglePrimitive.Root data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props}/>);
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
export { Toggle, toggleVariants };
+16 -53
View File
@@ -1,61 +1,24 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props}/>);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props}/>
</TooltipProvider>
)
</TooltipProvider>);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props}/>;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
function TooltipContent({ className, sideOffset = 0, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (<TooltipPrimitive.Portal>
<TooltipPrimitive.Content data-slot="tooltip-content" sideOffset={sideOffset} className={cn("bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", className)} {...props}>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]"/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
</TooltipPrimitive.Portal>);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+17 -42
View File
@@ -4,103 +4,88 @@ import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
const STORAGE_KEY = "spotiflac_audio_analysis_state";
export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(() => {
// Load from sessionStorage on mount - only detail, no spectrum
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.filePath && parsed.result) {
// Return result WITHOUT spectrum - spectrum will be loaded async
return {
...parsed.result,
spectrum: undefined,
};
}
}
} catch (err) {
}
catch (err) {
console.error("Failed to load saved analysis state:", err);
}
return null;
});
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
// Load file path from sessionStorage
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
return parsed.filePath || "";
}
} catch (err) {
// Ignore
}
catch (err) {
}
return "";
});
const [error, setError] = useState<string | null>(null);
const [spectrumLoading, setSpectrumLoading] = useState(() => {
// If result exists from sessionStorage, show loading for spectrum
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.filePath && parsed.result) {
// Always show loading initially, will be resolved async
return true;
}
}
} catch (err) {
// Ignore
}
catch (err) {
}
return false;
});
const analyzeFile = useCallback(async (filePath: string) => {
if (!filePath) {
setError("No file path provided");
return null;
}
setAnalyzing(true);
setError(null);
setResult(null);
setSelectedFilePath(filePath);
try {
logger.info(`Analyzing audio file: ${filePath}`);
const startTime = Date.now();
const response = await AnalyzeTrack(filePath);
const analysisResult: AnalysisResult = JSON.parse(response);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`);
// Save spectrum to memory cache
if (analysisResult.spectrum) {
setSpectrumCache(filePath, analysisResult.spectrum);
}
// Save detail (without spectrum) to sessionStorage
const { spectrum, ...detailResult } = analysisResult;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
filePath,
result: detailResult,
}));
} catch (err) {
}
catch (err) {
console.error("Failed to save analysis state:", err);
}
setResult(analysisResult);
setSpectrumLoading(false); // Spectrum is now available
setSpectrumLoading(false);
return analysisResult;
} catch (err) {
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`);
setError(errorMessage);
@@ -108,32 +93,26 @@ export function useAudioAnalysis() {
description: errorMessage,
});
return null;
} finally {
}
finally {
setAnalyzing(false);
}
}, []);
const clearResult = useCallback(() => {
setResult(null);
setError(null);
setSelectedFilePath("");
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch (err) {
// Ignore
}
catch (err) {
}
clearSpectrumCache();
}, []);
// Load spectrum from cache asynchronously after detail is displayed
useEffect(() => {
// Only load spectrum if we have result without spectrum and are in loading state
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
return;
}
// Load spectrum asynchronously to avoid blocking UI
// Use requestAnimationFrame to ensure detail renders first
let rafId: number;
const loadSpectrum = () => {
rafId = requestAnimationFrame(() => {
@@ -141,25 +120,21 @@ export function useAudioAnalysis() {
if (cachedSpectrum) {
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
setSpectrumLoading(false);
} else {
// Spectrum not in cache - user needs to re-analyze
}
else {
setSpectrumLoading(false);
}
});
};
// Double RAF to ensure detail is fully rendered
requestAnimationFrame(() => {
requestAnimationFrame(loadSpectrum);
});
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [result, selectedFilePath, spectrumLoading]);
return {
analyzing,
result,
+4 -13
View File
@@ -2,61 +2,52 @@ import { useState, useCallback } from "react";
import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
import type { TrackAvailability } from "@/types/api";
import { logger } from "@/lib/logger";
export function useAvailability() {
const [checking, setChecking] = useState(false);
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
const [error, setError] = useState<string | null>(null);
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
if (!spotifyId) {
setError("No Spotify ID provided");
return null;
}
// Check if already cached
if (availabilityMap.has(spotifyId)) {
return availabilityMap.get(spotifyId)!;
}
setChecking(true);
setCheckingTrackId(spotifyId);
setError(null);
try {
logger.info(`Checking availability for track: ${spotifyId}`);
const response = await CheckTrackAvailability(spotifyId, isrc || "");
const availability: TrackAvailability = JSON.parse(response);
setAvailabilityMap((prev) => {
const newMap = new Map(prev);
newMap.set(spotifyId, availability);
return newMap;
});
logger.success(`Availability check completed for ${spotifyId}`);
return availability;
} catch (err) {
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
logger.error(`Availability check error: ${errorMessage}`);
setError(errorMessage);
return null;
} finally {
}
finally {
setChecking(false);
setCheckingTrackId(null);
}
}, [availabilityMap]);
const getAvailability = useCallback((spotifyId: string) => {
return availabilityMap.get(spotifyId);
}, [availabilityMap]);
const clearAvailability = useCallback(() => {
setAvailabilityMap(new Map());
setError(null);
}, []);
return {
checking,
checkingTrackId,
+22 -63
View File
@@ -5,7 +5,6 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
export function useCover() {
const [downloadingCover, setDownloadingCover] = useState(false);
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
@@ -15,35 +14,19 @@ export function useCover() {
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
const stopBulkDownloadRef = useRef(false);
const handleDownloadCover = async (
coverUrl: string,
trackName: string,
artistName: string,
albumName?: string,
playlistName?: string,
position?: number,
trackId?: string,
albumArtist?: string,
releaseDate?: string,
discNumber?: number
) => {
const handleDownloadCover = async (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => {
if (!coverUrl) {
toast.error("No cover URL found for this track");
return;
}
const id = trackId || `${trackName}-${artistName}`;
logger.info(`downloading cover: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingCover(true);
setDownloadingCoverTrack(id);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
@@ -52,25 +35,21 @@ export function useCover() {
track: position,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
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));
}
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const response = await downloadCover({
cover_url: coverUrl,
track_name: trackName,
@@ -84,12 +63,12 @@ export function useCover() {
position: position || 0,
disc_number: discNumber || 0,
});
if (response.success) {
if (response.already_exists) {
toast.info("Cover file already exists");
setSkippedCovers((prev) => new Set(prev).add(id));
} else {
}
else {
toast.success("Cover downloaded successfully");
setDownloadedCovers((prev) => new Set(prev).add(id));
}
@@ -98,65 +77,53 @@ export function useCover() {
newSet.delete(id);
return newSet;
});
} else {
}
else {
toast.error(response.error || "Failed to download cover");
setFailedCovers((prev) => new Set(prev).add(id));
}
} catch (err) {
}
catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download cover");
setFailedCovers((prev) => new Set(prev).add(id));
} finally {
}
finally {
setDownloadingCover(false);
setDownloadingCoverTrack(null);
}
};
const handleDownloadAllCovers = async (
tracks: TrackMetadata[],
playlistName?: string
) => {
const handleDownloadAllCovers = async (tracks: TrackMetadata[], playlistName?: string, isAlbum?: boolean) => {
if (tracks.length === 0) {
toast.error("No tracks to download covers");
return;
}
const settings = getSettings();
setIsBulkDownloadingCovers(true);
setCoverDownloadProgress(0);
stopBulkDownloadRef.current = false;
let completed = 0;
let success = 0;
let skipped = 0;
let failed = 0;
for (let i = 0; i < tracks.length; i++) {
if (stopBulkDownloadRef.current) {
toast.info("Cover download stopped");
break;
}
const track = tracks[i];
if (!track.images) {
completed++;
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
continue;
}
const id = track.spotify_id || `${track.name}-${track.artists}`;
setDownloadingCoverTrack(id);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
// Determine if we should use album track number or sequential position
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
@@ -164,25 +131,21 @@ export function useCover() {
track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
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));
}
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const response = await downloadCover({
cover_url: track.images,
track_name: track.name,
@@ -196,47 +159,43 @@ export function useCover() {
position: trackPosition,
disc_number: track.disc_number,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedCovers((prev) => new Set(prev).add(id));
} else {
}
else {
success++;
setDownloadedCovers((prev) => new Set(prev).add(id));
}
} else {
}
else {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
}
} catch {
}
catch {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
}
completed++;
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
}
setDownloadingCoverTrack(null);
setIsBulkDownloadingCovers(false);
setCoverDownloadProgress(0);
if (!stopBulkDownloadRef.current) {
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
}
};
const handleStopCoverDownload = () => {
stopBulkDownloadRef.current = true;
};
const resetCoverState = () => {
setDownloadedCovers(new Set());
setFailedCovers(new Set());
setSkippedCovers(new Set());
};
return {
downloadingCover,
downloadingCoverTrack,
File diff suppressed because it is too large Load Diff
+2 -12
View File
@@ -1,12 +1,10 @@
import { useState, useEffect, useRef } from "react";
import { GetDownloadProgress } from "../../wailsjs/go/main/App";
export interface DownloadProgressInfo {
is_downloading: boolean;
mb_downloaded: number;
speed_mbps: number;
}
export function useDownloadProgress() {
const [progress, setProgress] = useState<DownloadProgressInfo>({
is_downloading: false,
@@ -14,31 +12,23 @@ export function useDownloadProgress() {
speed_mbps: 0,
});
const intervalRef = useRef<number | null>(null);
useEffect(() => {
// Poll progress every 200ms for smooth updates
const pollProgress = async () => {
try {
const progressInfo = await GetDownloadProgress();
setProgress(progressInfo);
} catch (error) {
}
catch (error) {
console.error("Failed to get download progress:", error);
}
};
// Start polling
intervalRef.current = window.setInterval(pollProgress, 200);
// Initial fetch
pollProgress();
// Cleanup
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return progress;
}
+4 -13
View File
@@ -1,10 +1,8 @@
import { useEffect, useState } from "react";
import { GetDownloadQueue } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
export function useDownloadQueueData() {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
new backend.DownloadQueueInfo({
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
@@ -14,27 +12,20 @@ export function useDownloadQueueData() {
completed_count: 0,
failed_count: 0,
skipped_count: 0,
})
);
}));
useEffect(() => {
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
}
catch (error) {
console.error("Failed to get download queue:", error);
}
};
// Initial fetch
fetchQueue();
// Poll every 200ms
const interval = setInterval(fetchQueue, 200);
return () => clearInterval(interval);
}, []);
return queueInfo;
}
@@ -1,12 +1,9 @@
import { useState } from "react";
export function useDownloadQueueDialog() {
const [isOpen, setIsOpen] = useState(false);
const openQueue = () => setIsOpen(true);
const closeQueue = () => setIsOpen(false);
const toggleQueue = () => setIsOpen((prev) => !prev);
return {
isOpen,
openQueue,
+22 -67
View File
@@ -5,7 +5,6 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
export function useLyrics() {
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
@@ -14,33 +13,17 @@ export function useLyrics() {
const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
const stopBulkDownloadRef = useRef(false);
const handleDownloadLyrics = async (
spotifyId: string,
trackName: string,
artistName: string,
albumName?: string,
playlistName?: string,
position?: number,
albumArtist?: string,
releaseDate?: string,
discNumber?: number
) => {
const handleDownloadLyrics = async (spotifyId: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => {
if (!spotifyId) {
toast.error("No Spotify ID found for this track");
return;
}
logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingLyricsTrack(spotifyId);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Build output path using template system
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
@@ -49,27 +32,22 @@ export function useLyrics() {
track: position,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
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));
}
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const response = await downloadLyrics({
spotify_id: spotifyId,
track_name: trackName,
@@ -84,12 +62,12 @@ export function useLyrics() {
use_album_track_number: useAlbumTrackNumber,
disc_number: discNumber,
});
if (response.success) {
if (response.already_exists) {
toast.info("Lyrics file already exists");
setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
} else {
}
else {
toast.success("Lyrics downloaded successfully");
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
}
@@ -98,65 +76,50 @@ export function useLyrics() {
newSet.delete(spotifyId);
return newSet;
});
} else {
}
else {
toast.error(response.error || "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
}
} catch (err) {
}
catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
} finally {
}
finally {
setDownloadingLyricsTrack(null);
}
};
const handleDownloadAllLyrics = async (
tracks: TrackMetadata[],
playlistName?: string,
_isArtistDiscography?: boolean
) => {
const handleDownloadAllLyrics = async (tracks: TrackMetadata[], playlistName?: string, _isArtistDiscography?: boolean, isAlbum?: boolean) => {
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
if (tracksWithSpotifyId.length === 0) {
toast.error("No tracks with Spotify ID available for lyrics download");
return;
}
const settings = getSettings();
setIsBulkDownloadingLyrics(true);
setLyricsDownloadProgress(0);
stopBulkDownloadRef.current = false;
let completed = 0;
let success = 0;
let failed = 0;
let skipped = 0;
const total = tracksWithSpotifyId.length;
for (let i = 0; i < tracksWithSpotifyId.length; i++) {
const track = tracksWithSpotifyId[i];
if (stopBulkDownloadRef.current) {
toast.info("Lyrics download stopped by user");
break;
}
const id = track.spotify_id!;
setDownloadingLyricsTrack(id);
setLyricsDownloadProgress(Math.round((completed / total) * 100));
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
const placeholder = "__SLASH_PLACEHOLDER__";
// Determine if we should use album track number or sequential position
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
@@ -164,25 +127,21 @@ export function useLyrics() {
track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
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));
}
// Apply folder template
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
// Restore any slashes that were in the original values as spaces
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const response = await downloadLyrics({
spotify_id: id,
track_name: track.name,
@@ -197,12 +156,12 @@ export function useLyrics() {
use_album_track_number: useAlbumTrackNumber,
disc_number: track.disc_number,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedLyrics((prev) => new Set(prev).add(id));
} else {
}
else {
success++;
setDownloadedLyrics((prev) => new Set(prev).add(id));
}
@@ -211,40 +170,36 @@ export function useLyrics() {
newSet.delete(id);
return newSet;
});
} else {
}
else {
failed++;
setFailedLyrics((prev) => new Set(prev).add(id));
}
} catch (err) {
}
catch (err) {
failed++;
logger.error(`error downloading lyrics: ${track.name} - ${err}`);
setFailedLyrics((prev) => new Set(prev).add(id));
}
completed++;
}
setDownloadingLyricsTrack(null);
setIsBulkDownloadingLyrics(false);
setLyricsDownloadProgress(0);
if (!stopBulkDownloadRef.current) {
toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
}
};
const handleStopLyricsDownload = () => {
logger.info("lyrics download stopped by user");
stopBulkDownloadRef.current = true;
toast.info("Stopping lyrics download...");
};
const resetLyricsState = () => {
setDownloadedLyrics(new Set());
setFailedLyrics(new Set());
setSkippedLyrics(new Set());
};
return {
downloadingLyricsTrack,
downloadedLyrics,
+59 -46
View File
@@ -3,7 +3,6 @@ import { fetchSpotifyMetadata } from "@/lib/api";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { logger } from "@/lib/logger";
import type { SpotifyMetadataResponse } from "@/types/api";
export function useMetadata() {
const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
@@ -17,114 +16,124 @@ export function useMetadata() {
external_urls: string;
} | null>(null);
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
const getUrlType = (url: string): string => {
if (url.includes("/track/")) return "track";
if (url.includes("/album/")) return "album";
if (url.includes("/playlist/")) return "playlist";
if (url.includes("/artist/")) return "artist";
if (url.includes("/track/"))
return "track";
if (url.includes("/album/"))
return "album";
if (url.includes("/playlist/"))
return "playlist";
if (url.includes("/artist/"))
return "artist";
return "unknown";
};
const fetchMetadataDirectly = async (url: string) => {
const urlType = getUrlType(url);
logger.info(`fetching ${urlType} metadata...`);
logger.debug(`url: ${url}`);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(url);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
if ("playlist_info" in data) {
const playlistInfo = data.playlist_info;
if (!playlistInfo.owner.name && playlistInfo.tracks.total === 0 && data.track_list.length === 0) {
logger.warning("playlist appears to be empty or private");
toast.error("Playlist not found or may be private");
setMetadata(null);
return;
}
}
else if ("album_info" in data) {
const albumInfo = data.album_info;
if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
logger.warning("album appears to be empty or not found");
toast.error("Album not found or may be private");
setMetadata(null);
return;
}
}
setMetadata(data);
// Log detailed info based on type
if ("track" in data) {
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
} else if ("album_info" in data) {
}
else if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
} else if ("playlist_info" in data) {
}
else if ("playlist_info" in data) {
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
} else if ("artist_info" in data) {
}
else if ("artist_info" in data) {
logger.success(`fetched artist: ${data.artist_info.name}`);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Metadata fetched successfully");
} catch (err) {
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
} finally {
}
finally {
setLoading(false);
}
};
const handleFetchMetadata = async (url: string) => {
if (!url.trim()) {
logger.warning("empty url provided");
toast.error("Please enter a Spotify URL");
return;
}
let urlToFetch = url.trim();
const isArtistUrl = urlToFetch.includes("/artist/");
if (isArtistUrl && !urlToFetch.includes("/discography")) {
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
logger.debug("converted to discography url");
}
if (isArtistUrl) {
logger.info("artist url detected, showing timeout dialog");
setPendingUrl(urlToFetch);
setPendingArtistName(null); // Clear artist name for URL input
setPendingArtistName(null);
setShowTimeoutDialog(true);
} else {
}
else {
await fetchMetadataDirectly(urlToFetch);
}
return urlToFetch;
};
const handleConfirmFetch = async () => {
setShowTimeoutDialog(false);
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
logger.debug(`url: ${pendingUrl}`);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
setMetadata(data);
if ("artist_info" in data) {
logger.success(`fetched artist: ${data.artist_info.name}`);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Metadata fetched successfully");
} catch (err) {
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
} finally {
}
finally {
setLoading(false);
}
};
const handleAlbumClick = (album: {
id: string;
name: string;
@@ -134,7 +143,6 @@ export function useMetadata() {
setSelectedAlbum(album);
setShowAlbumDialog(true);
};
const handleArtistClick = async (artist: {
id: string;
name: string;
@@ -147,43 +155,48 @@ export function useMetadata() {
setShowTimeoutDialog(true);
return artistUrl;
};
const handleConfirmAlbumFetch = async () => {
if (!selectedAlbum) return;
if (!selectedAlbum)
return;
const albumUrl = selectedAlbum.external_urls;
logger.info(`fetching album: ${selectedAlbum.name}...`);
logger.debug(`url: ${albumUrl}`);
setShowAlbumDialog(false);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(albumUrl);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
if ("album_info" in data) {
const albumInfo = data.album_info;
if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
logger.warning("album appears to be empty or not found");
toast.error("Album not found or may be private");
setMetadata(null);
setSelectedAlbum(null);
return albumUrl;
}
}
setMetadata(data);
if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Album metadata fetched successfully");
return albumUrl;
} catch (err) {
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
} finally {
}
finally {
setLoading(false);
setSelectedAlbum(null);
}
};
return {
loading,
metadata,
+75
View File
@@ -0,0 +1,75 @@
import { useState } from "react";
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
import { toast } from "sonner";
export function usePreview() {
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
const playPreview = async (trackId: string, trackName: string) => {
try {
if (playingTrack === trackId && currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setPlayingTrack(null);
setCurrentAudio(null);
return;
}
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null);
}
setLoadingPreview(trackId);
const previewURL = await GetPreviewURL(trackId);
if (!previewURL) {
toast.error("Preview not available", {
description: `No preview found for "${trackName}"`,
});
setLoadingPreview(null);
return;
}
const audio = new Audio(previewURL);
audio.addEventListener("loadeddata", () => {
setLoadingPreview(null);
setPlayingTrack(trackId);
});
audio.addEventListener("ended", () => {
setPlayingTrack(null);
setCurrentAudio(null);
});
audio.addEventListener("error", () => {
toast.error("Failed to play preview", {
description: `Could not play preview for "${trackName}"`,
});
setLoadingPreview(null);
setPlayingTrack(null);
setCurrentAudio(null);
});
setCurrentAudio(audio);
await audio.play();
}
catch (error: any) {
console.error("Preview error:", error);
toast.error("Preview not available", {
description: error?.message || `Could not load preview for "${trackName}"`,
});
setLoadingPreview(null);
setPlayingTrack(null);
}
};
const stopPreview = () => {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null);
}
};
return {
playPreview,
stopPreview,
loadingPreview,
playingTrack,
};
}
+23 -10
View File
@@ -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 */
+18 -34
View File
@@ -1,59 +1,43 @@
import type {
SpotifyMetadataResponse,
DownloadRequest,
DownloadResponse,
HealthResponse,
LyricsDownloadRequest,
LyricsDownloadResponse,
CoverDownloadRequest,
CoverDownloadResponse,
} from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App";
import { main } from "../../wailsjs/go/models";
export async function fetchSpotifyMetadata(
url: string,
batch: boolean = true,
delay: number = 1.0,
timeout: number = 300.0
): Promise<SpotifyMetadataResponse> {
export async function fetchSpotifyMetadata(url: string, batch: boolean = true, delay: number = 1.0, timeout: number = 300.0): Promise<SpotifyMetadataResponse> {
const req = new main.SpotifyMetadataRequest({
url,
batch,
delay,
timeout,
});
const jsonString = await GetSpotifyMetadata(req);
return JSON.parse(jsonString);
}
export async function downloadTrack(
request: DownloadRequest
): Promise<DownloadResponse> {
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request);
return await DownloadTrack(req);
}
export async function checkHealth(): Promise<HealthResponse> {
// For Wails, we can just return a simple health check
// since the app is running locally
return {
status: "ok",
time: new Date().toISOString(),
};
}
export async function downloadLyrics(
request: LyricsDownloadRequest
): Promise<LyricsDownloadResponse> {
export async function downloadLyrics(request: LyricsDownloadRequest): Promise<LyricsDownloadResponse> {
const req = new main.LyricsDownloadRequest(request);
return await DownloadLyrics(req);
}
export async function downloadCover(
request: CoverDownloadRequest
): Promise<CoverDownloadResponse> {
export async function downloadCover(request: CoverDownloadRequest): Promise<CoverDownloadResponse> {
const req = new main.CoverDownloadRequest(request);
return await DownloadCover(req);
}
export async function downloadHeader(request: HeaderDownloadRequest): Promise<HeaderDownloadResponse> {
const req = new main.HeaderDownloadRequest(request);
return await DownloadHeader(req);
}
export async function downloadGalleryImage(request: GalleryImageDownloadRequest): Promise<GalleryImageDownloadResponse> {
const req = new main.GalleryImageDownloadRequest(request);
return await DownloadGalleryImage(req);
}
export async function downloadAvatar(request: AvatarDownloadRequest): Promise<AvatarDownloadResponse> {
const req = new main.AvatarDownloadRequest(request);
return await DownloadAvatar(req);
}
+12 -47
View File
@@ -1,106 +1,71 @@
// Audio utility for toast notifications using Web Audio API
class AudioManager {
private audioContext: AudioContext | null = null;
private getAudioContext(): AudioContext {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
}
return this.audioContext;
}
// Generate a simple tone using oscillator
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
} catch (error) {
}
catch (error) {
console.error('Error playing audio:', error);
}
}
// Success sound - pleasant ascending tones
playSuccess() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(523.25, 0.08, 'sine', 0.2, now); // C5
// Second tone
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); // E5
// Third tone
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); // G5
this.playToneAt(523.25, 0.08, 'sine', 0.2, now);
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08);
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16);
}
// Error sound - descending tones
playError() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(392.00, 0.1, 'square', 0.15, now); // G4
// Second tone
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); // E4
this.playToneAt(392.00, 0.1, 'square', 0.15, now);
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1);
}
// Warning sound - alternating tones
playWarning() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); // A4
// Second tone
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); // B4
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now);
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12);
}
// Info sound - single soft tone
playInfo() {
this.playTone(523.25, 0.15, 'sine', 0.15); // C5
this.playTone(523.25, 0.15, 'sine', 0.15);
}
// Helper method to play tone at specific time
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
} catch (error) {
}
catch (error) {
console.error('Error playing audio:', error);
}
}
}
// Export singleton instance
export const audioManager = new AudioManager();
// Helper functions for easy use
export const playSuccessSound = () => audioManager.playSuccess();
export const playErrorSound = () => audioManager.playError();
export const playWarningSound = () => audioManager.playWarning();
-13
View File
@@ -1,16 +1,13 @@
export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
export interface LogEntry {
timestamp: Date;
level: LogLevel;
message: string;
}
class Logger {
private logs: LogEntry[] = [];
private maxLogs = 500;
private listeners: Set<() => void> = new Set();
private addLog(level: LogLevel, message: string) {
const entry: LogEntry = {
timestamp: new Date(),
@@ -23,44 +20,34 @@ class Logger {
}
this.notifyListeners();
}
info(message: string) {
this.addLog("info", message);
}
success(message: string) {
this.addLog("success", message);
}
warning(message: string) {
this.addLog("warning", message);
}
error(message: string) {
this.addLog("error", message);
}
debug(message: string) {
this.addLog("debug", message);
}
getLogs(): LogEntry[] {
return [...this.logs];
}
clear() {
this.logs = [];
this.notifyListeners();
}
subscribe(listener: () => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners() {
this.listeners.forEach((listener) => listener());
}
}
export const logger = new Logger();
+14 -16
View File
@@ -1,14 +1,9 @@
/**
* Format a date to relative time string with max 2 units
* e.g., "23 hours 32 minutes ago", "1 day 14 hours ago"
*/
export function formatRelativeTime(date: Date | string | number): string {
const now = new Date();
const target = new Date(date);
const diffMs = now.getTime() - target.getTime();
if (diffMs < 0) return "just now";
if (diffMs < 0)
return "just now";
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
@@ -16,44 +11,47 @@ export function formatRelativeTime(date: Date | string | number): string {
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
const remainingMonths = Math.floor((days % 365) / 30);
if (remainingMonths > 0) {
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
}
} else if (months > 0) {
}
else if (months > 0) {
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
const remainingDays = days % 30;
if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} else if (weeks > 0) {
}
else if (weeks > 0) {
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
const remainingDays = days % 7;
if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} else if (days > 0) {
}
else if (days > 0) {
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
const remainingHours = hours % 24;
if (remainingHours > 0) {
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
}
} else if (hours > 0) {
}
else if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
const remainingMinutes = minutes % 60;
if (remainingMinutes > 0) {
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
}
} else if (minutes > 0) {
}
else if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
} else {
}
else {
return "just now";
}
return "Released " + parts.slice(0, 2).join(" ") + " ago";
}
+141 -72
View File
@@ -1,25 +1,17 @@
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";
// Folder structure presets
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";
// Filename format presets
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 {
downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon";
theme: string;
themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily;
// New template system
folderPreset: FolderPreset;
folderTemplate: string;
filenamePreset: FilenamePreset;
filenameTemplate: string;
// Legacy settings (kept for migration)
filenameFormat?: "title-artist" | "artist-title" | "title";
artistSubfolder?: boolean;
albumSubfolder?: boolean;
@@ -28,13 +20,14 @@ export interface Settings {
embedLyrics: boolean;
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
// Quality settings for specific sources
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
qobuzQuality: "6" | "7";
amazonQuality: "original";
}
// Folder preset templates
export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: string }> = {
export const FOLDER_PRESETS: Record<FolderPreset, {
label: string;
template: string;
}> = {
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
@@ -51,9 +44,10 @@ export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: str
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
"custom": { label: "Custom...", template: "{artist}/{album}" },
};
// Filename preset templates
export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template: string }> = {
export const FILENAME_PRESETS: Record<FilenamePreset, {
label: string;
template: string;
}> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
@@ -68,8 +62,6 @@ export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template:
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
// Available template variables
export const TEMPLATE_VARIABLES = [
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
@@ -79,8 +71,6 @@ export const TEMPLATE_VARIABLES = [
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" },
];
// Auto-detect operating system
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
if (platform.includes('win')) {
@@ -88,7 +78,6 @@ function detectOS(): "Windows" | "linux/MacOS" {
}
return "linux/MacOS";
}
export const DEFAULT_SETTINGS: Settings = {
downloadPath: "",
downloader: "auto",
@@ -104,11 +93,16 @@ export const DEFAULT_SETTINGS: Settings = {
embedLyrics: false,
embedMaxQualityCover: false,
operatingSystem: detectOS(),
tidalQuality: "LOSSLESS", // Default: 16-bit lossless
qobuzQuality: "6" // Default: FLAC 16-bit
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "original"
};
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
export const FONT_OPTIONS: {
value: FontFamily;
label: string;
fontFamily: string;
}[] = [
{ 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' },
@@ -126,7 +120,6 @@ export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: strin
{ 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) {
@@ -134,43 +127,43 @@ export function applyFont(fontFamily: FontFamily): void {
document.body.style.fontFamily = font.fontFamily;
}
}
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
return data.downloadPath || "";
} catch (error) {
}
catch (error) {
console.error("Failed to fetch default path:", error);
return "";
}
}
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) {
const parsed = JSON.parse(stored);
// Migrate old darkMode to themeMode
if ('darkMode' in parsed && !('themeMode' in parsed)) {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
// Migrate old folder/filename settings to new template system
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) {
}
else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
} else if (hasAlbum) {
}
else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
} else {
}
else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
@@ -180,32 +173,116 @@ export function getSettings(): Settings {
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
} else if (format === "artist-title") {
}
else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
} else {
}
else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
// Always use detected OS (don't persist it)
parsed.operatingSystem = detectOS();
// Set default quality if not present
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";
}
return { ...DEFAULT_SETTINGS, ...parsed };
}
} catch (error) {
console.error("Failed to load settings:", error);
}
catch (error) {
console.error("Failed to load settings from local storage:", error);
}
return DEFAULT_SETTINGS;
}
// Parse template and replace variables with actual values
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;
@@ -214,16 +291,12 @@ export interface TemplateData {
track?: number;
disc?: number;
year?: string;
isrc?: string;
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
if (!template) return "";
if (!template)
return "";
let result = template;
// Replace each variable
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
@@ -231,57 +304,53 @@ export function parseTemplate(template: string, data: TemplateData): string {
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{playlist\}/g, data.playlist || "");
return result;
}
export async function getSettingsWithDefaults(): Promise<Settings> {
const settings = getSettings();
// If downloadPath is empty, fetch from backend
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));
} catch (error) {
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 {
if (mode === "auto") {
// Check system preference
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
document.documentElement.classList.add("dark");
} else {
}
else {
document.documentElement.classList.remove("dark");
}
} else if (mode === "dark") {
}
else if (mode === "dark") {
document.documentElement.classList.add("dark");
} else {
}
else {
document.documentElement.classList.remove("dark");
}
}
+2 -8
View File
@@ -1,21 +1,15 @@
// Memory cache for spectrum data (fast access, cleared on page refresh)
// Key: file path, Value: spectrum data
const spectrumCache = new Map<string, any>();
export function setSpectrumCache(filePath: string, spectrumData: any): void {
spectrumCache.set(filePath, spectrumData);
}
export function getSpectrumCache(filePath: string): any | null {
return spectrumCache.get(filePath) || null;
}
export function clearSpectrumCache(filePath?: string): void {
if (filePath) {
spectrumCache.delete(filePath);
} else {
}
else {
spectrumCache.clear();
}
}
+1 -16
View File
@@ -6,8 +6,6 @@ export interface Theme {
dark: Record<string, string>;
};
}
// Base colors yang sama untuk semua tema (kecuali primary dan primary-foreground)
const baseLightColors: Record<string, string> = {
background: "oklch(1 0 0)",
foreground: "oklch(0.145 0 0)",
@@ -26,7 +24,6 @@ const baseLightColors: Record<string, string> = {
input: "oklch(0.922 0 0)",
ring: "oklch(0.708 0 0)",
};
const baseDarkColors: Record<string, string> = {
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
@@ -45,8 +42,6 @@ const baseDarkColors: Record<string, string> = {
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
};
// Primary colors yang berbeda untuk setiap tema
interface PrimaryColors {
light: {
primary: string;
@@ -57,7 +52,6 @@ interface PrimaryColors {
"primary-foreground": string;
};
}
const primaryColors: Record<string, PrimaryColors> = {
amber: {
light: {
@@ -240,13 +234,7 @@ const primaryColors: Record<string, PrimaryColors> = {
},
},
};
// Helper function untuk menggabungkan base colors dengan primary colors
function createTheme(
name: string,
label: string,
primary: PrimaryColors
): Theme {
function createTheme(name: string, label: string, primary: PrimaryColors): Theme {
return {
name,
label,
@@ -256,7 +244,6 @@ function createTheme(
},
};
}
export const themes: Theme[] = [
createTheme("amber", "Amber", primaryColors.amber),
createTheme("blue", "Blue", primaryColors.blue),
@@ -277,13 +264,11 @@ export const themes: Theme[] = [
createTheme("violet", "Violet", primaryColors.violet),
createTheme("yellow", "Yellow", primaryColors.yellow),
].sort((a, b) => a.name.localeCompare(b.name));
export function applyTheme(themeName: string) {
const theme = themes.find((t) => t.name === themeName) || themes[0];
const root = document.documentElement;
const isDark = root.classList.contains("dark");
const vars = isDark ? theme.cssVars.dark : theme.cssVars.light;
Object.entries(vars).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value);
});
+11 -21
View File
@@ -1,55 +1,45 @@
import { toast } from "sonner";
import {
playSuccessSound,
playErrorSound,
playWarningSound,
playInfoSound,
} from "./audio";
import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound, } from "./audio";
import { logger } from "./logger";
import { getSettings } from "./settings";
const toastStyle = {
className: "font-mono lowercase",
};
// Helper to check if SFX is enabled
const isSfxEnabled = () => getSettings().sfxEnabled;
// Wrapper functions for toast with sound effects
export const toastWithSound = {
success: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.success(msg);
if (isSfxEnabled()) playSuccessSound();
if (isSfxEnabled())
playSuccessSound();
return toast.success(msg, { ...toastStyle, ...data });
},
error: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.error(msg);
if (isSfxEnabled()) playErrorSound();
if (isSfxEnabled())
playErrorSound();
return toast.error(msg, { ...toastStyle, ...data });
},
warning: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.warning(msg);
if (isSfxEnabled()) playWarningSound();
if (isSfxEnabled())
playWarningSound();
return toast.warning(msg, { ...toastStyle, ...data });
},
info: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.info(msg);
if (isSfxEnabled()) playInfoSound();
if (isSfxEnabled())
playInfoSound();
return toast.info(msg, { ...toastStyle, ...data });
},
// Default toast without specific type
message: (message: string, data?: any) => {
const msg = message.toLowerCase();
logger.info(msg);
if (isSfxEnabled()) playInfoSound();
if (isSfxEnabled())
playInfoSound();
return toast(msg, { ...toastStyle, ...data });
},
};

Some files were not shown because too many files have changed in this diff Show More