Compare commits

...

23 Commits

Author SHA1 Message Date
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
afkarxyz d7b0ca8b3c v7.0 2025-12-24 09:09:39 +07:00
afkarxyz 8e6a1196b5 v7.0 2025-12-24 08:55:23 +07:00
afkarxyz c150124273 v7.0 2025-12-24 08:50:43 +07:00
105 changed files with 12399 additions and 12210 deletions
+1 -1
View File
@@ -61,4 +61,4 @@ test
# Build notes (optional - uncomment if you don't want to commit) # Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md # 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) [![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) ![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<div align="center"> <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) ![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) ![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> </div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases) ### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
## Screenshot ## Screenshot
![Image](https://github.com/user-attachments/assets/83a5e0f7-a225-4ead-a058-4979722b45f0) ![Image](https://github.com/user-attachments/assets/4bc2d45a-8afc-4c91-9d57-afdbd2b9c225)
## Other project ## Other projects
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader) ### [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) [![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 ~
+454 -154
View File
File diff suppressed because it is too large Load Diff
+42 -53
View File
@@ -63,16 +63,14 @@ func (a *AmazonDownloader) getRandomUserAgent() string {
} }
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) { 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() now := time.Now()
if now.Sub(a.apiCallResetTime) >= time.Minute { if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0 a.apiCallCount = 0
a.apiCallResetTime = now a.apiCallResetTime = now
} }
// If we've hit the limit, wait until the next minute if a.apiCallCount >= 9 {
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
waitTime := time.Minute - now.Sub(a.apiCallResetTime) waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 { if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
@@ -82,10 +80,9 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
} }
} }
// Add delay between requests (6 seconds = 10 requests per minute)
if !a.lastAPICallTime.IsZero() { if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime) timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second // 7 seconds to be safe minDelay := 7 * time.Second
if timeSinceLastCall < minDelay { if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
@@ -93,7 +90,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
} }
} }
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -109,7 +105,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
fmt.Println("Getting Amazon URL...") fmt.Println("Getting Amazon URL...")
// Retry logic for rate limit errors
maxRetries := 3 maxRetries := 3
var resp *http.Response var resp *http.Response
for i := 0; i < maxRetries; i++ { for i := 0; i < maxRetries; i++ {
@@ -118,11 +113,10 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return "", fmt.Errorf("failed to get Amazon URL: %w", err) return "", fmt.Errorf("failed to get Amazon URL: %w", err)
} }
// Update rate limit tracking
a.lastAPICallTime = time.Now() a.lastAPICallTime = time.Now()
a.apiCallCount++ a.apiCallCount++
if resp.StatusCode == 429 { // Too Many Requests if resp.StatusCode == 429 {
resp.Body.Close() resp.Body.Close()
if i < maxRetries-1 { if i < maxRetries-1 {
waitTime := 15 * time.Second waitTime := 15 * time.Second
@@ -142,7 +136,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read body first to handle encoding issues and provide better error messages
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err) return "", fmt.Errorf("failed to read response body: %w", err)
@@ -154,7 +147,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
var songLinkResp SongLinkResponse var songLinkResp SongLinkResponse
if err := json.Unmarshal(body, &songLinkResp); err != nil { if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body) bodyStr := string(body)
if len(bodyStr) > 200 { if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..." bodyStr = bodyStr[:200] + "..."
@@ -169,7 +162,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
amazonURL := amazonLink.URL amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if strings.Contains(amazonURL, "trackAsin=") { if strings.Contains(amazonURL, "trackAsin=") {
parts := strings.Split(amazonURL, "trackAsin=") parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 { if len(parts) > 1 {
@@ -188,12 +180,11 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
for _, region := range a.regions { for _, region := range a.regions {
fmt.Printf("\nTrying region: %s...\n", region) fmt.Printf("\nTrying region: %s...\n", region)
// Decode base64 service URL
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request
encodedURL := url.QueryEscape(amazonURL) encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
@@ -234,7 +225,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
downloadID := submitResp.ID downloadID := submitResp.ID
fmt.Printf("Download ID: %s\n", downloadID) fmt.Printf("Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID) statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("Waiting for download to complete...") fmt.Println("Waiting for download to complete...")
@@ -276,7 +266,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
if status.Status == "done" { if status.Status == "done" {
fmt.Println("\nDownload ready!") fmt.Println("\nDownload ready!")
// Build download URL
fileURL := status.URL fileURL := status.URL
if strings.HasPrefix(fileURL, "./") { if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:]) fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
@@ -289,7 +278,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
fmt.Printf("Downloading: %s - %s\n", artist, trackName) fmt.Printf("Downloading: %s - %s\n", artist, trackName)
// Download file
downloadReq, err := http.NewRequest("GET", fileURL, nil) downloadReq, err := http.NewRequest("GET", fileURL, nil)
if err != nil { if err != nil {
lastError = fmt.Errorf("failed to create download request: %w", err) lastError = fmt.Errorf("failed to create download request: %w", err)
@@ -310,7 +298,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
break break
} }
// Generate filename
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName) fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
for _, char := range `<>:"/\|?*` { for _, char := range `<>:"/\|?*` {
fileName = strings.ReplaceAll(fileName, string(char), "") fileName = strings.ReplaceAll(fileName, string(char), "")
@@ -319,7 +306,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
filePath := filepath.Join(outputDir, fileName) filePath := filepath.Join(outputDir, fileName)
// Save file
out, err := os.Create(filePath) out, err := os.Create(filePath)
if err != nil { if err != nil {
lastError = fmt.Errorf("failed to create file: %w", err) lastError = fmt.Errorf("failed to create file: %w", err)
@@ -328,7 +314,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
defer out.Close() defer out.Close()
fmt.Println("Downloading...") fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out) pw := NewProgressWriter(out)
_, err = io.Copy(pw, fileResp.Body) _, err = io.Copy(pw, fileResp.Body)
if err != nil { if err != nil {
@@ -336,7 +322,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("failed to write file: %w", err) 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.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete!") fmt.Println("Download complete!")
return filePath, nil return filePath, nil
@@ -349,7 +334,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
lastError = fmt.Errorf("processing failed: %s", errorMsg) lastError = fmt.Errorf("processing failed: %s", errorMsg)
break break
} else { } else {
// Still processing
friendlyStatus := status.FriendlyStatus friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" { if friendlyStatus == "" {
friendlyStatus = status.Status friendlyStatus = status.Status
@@ -372,17 +357,16 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("all regions failed. Last error: %v", lastError) return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
} }
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) { func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
// Create output directory if needed
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err) 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 != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, false) expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename) expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
@@ -393,47 +377,57 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
fmt.Printf("Using Amazon URL: %s\n", amazonURL) fmt.Printf("Using Amazon URL: %s\n", amazonURL)
// Download from service
filePath, err := a.DownloadFromService(amazonURL, outputDir) filePath, err := a.DownloadFromService(amazonURL, outputDir)
if err != nil { if err != nil {
return "", err return "", err
} }
// Rename file based on Spotify metadata
if spotifyTrackName != "" && spotifyArtistName != "" { if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName) safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName) safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
year := ""
if len(spotifyReleaseDate) >= 4 {
year = spotifyReleaseDate[:4]
}
// Build filename based on format settings
var newFilename string var newFilename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") { if strings.Contains(filenameFormat, "{") {
newFilename = filenameFormat newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle) newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist) newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
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 { if position > 0 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position)) newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else { } else {
// Remove {track} with common separators
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "") newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "") newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "") newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
} }
} else { } else {
// Legacy format support
switch filenameFormat { switch filenameFormat {
case "artist-title": case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title": case "title":
newFilename = safeTitle newFilename = safeTitle
default: // "title-artist" default:
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
} }
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 { if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename) newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
} }
@@ -442,7 +436,6 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = newFilename + ".flac" newFilename = newFilename + ".flac"
newFilePath := filepath.Join(outputDir, newFilename) newFilePath := filepath.Join(outputDir, newFilename)
// Rename file
if err := os.Rename(filePath, newFilePath); err != nil { if err := os.Rename(filePath, newFilePath); err != nil {
fmt.Printf("Warning: Failed to rename file: %v\n", err) fmt.Printf("Warning: Failed to rename file: %v\n", err)
} else { } else {
@@ -451,11 +444,10 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
} }
} }
// Embed Spotify metadata (replace Amazon's embedded metadata)
fmt.Println("Embedding Spotify metadata...") fmt.Println("Embedding Spotify metadata...")
coverPath := "" coverPath := ""
// Download Spotify cover (with max resolution if enabled)
if spotifyCoverURL != "" { if spotifyCoverURL != "" {
coverPath = filePath + ".cover.jpg" coverPath = filePath + ".cover.jpg"
coverClient := NewCoverClient() coverClient := NewCoverClient()
@@ -468,27 +460,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 trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 { if trackNumberToEmbed == 0 {
trackNumberToEmbed = position // Fallback to playlist position trackNumberToEmbed = 1
}
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1 // Default to track 1 for single track downloads without track number
} }
// Build metadata from Spotify
metadata := Metadata{ metadata := Metadata{
Title: spotifyTrackName, Title: spotifyTrackName,
Artist: spotifyArtistName, Artist: spotifyArtistName,
Album: spotifyAlbumName, Album: spotifyAlbumName,
AlbumArtist: spotifyAlbumArtist, AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD) Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed, TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber, // Disc number from Spotify DiscNumber: spotifyDiscNumber,
ISRC: spotifyISRC, // Use ISRC from Spotify TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
} }
@@ -503,12 +492,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
return filePath, nil return filePath, nil
} }
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) { func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
// Get Amazon URL from Spotify track ID
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil { if err != nil {
return "", err return "", err
} }
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover) return a.DownloadByURL(amazonURL, outputDir, 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" mewflac "github.com/mewkiz/flac"
) )
// AnalysisResult contains the audio analysis data
type AnalysisResult struct { type AnalysisResult struct {
FilePath string `json:"file_path"` FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"` FileSize int64 `json:"file_size"`
@@ -25,19 +24,16 @@ type AnalysisResult struct {
Spectrum *SpectrumData `json:"spectrum,omitempty"` Spectrum *SpectrumData `json:"spectrum,omitempty"`
} }
// AnalyzeTrack performs audio analysis on a FLAC file
func AnalyzeTrack(filepath string) (*AnalysisResult, error) { func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) { if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath) return nil, fmt.Errorf("file does not exist: %s", filepath)
} }
// Get file size
fileInfo, err := os.Stat(filepath) fileInfo, err := os.Stat(filepath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err) return nil, fmt.Errorf("failed to get file info: %w", err)
} }
// Parse FLAC file
f, err := flac.ParseFile(filepath) f, err := flac.ParseFile(filepath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err) return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
@@ -48,68 +44,55 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
FileSize: fileInfo.Size(), FileSize: fileInfo.Size(),
} }
// Extract basic audio properties from STREAMINFO block
if len(f.Meta) > 0 { if len(f.Meta) > 0 {
streamInfo := f.Meta[0] streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo { if streamInfo.Type == flac.StreamInfo {
// Read STREAMINFO data
data := streamInfo.Data data := streamInfo.Data
if len(data) >= 18 { 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 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 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 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 | result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 | uint64(data[14])<<24 |
uint64(data[15])<<16 | uint64(data[15])<<16 |
uint64(data[16])<<8 | uint64(data[16])<<8 |
uint64(data[17]) uint64(data[17])
// Calculate duration
if result.SampleRate > 0 { if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) 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) spectrum, err := AnalyzeSpectrum(filepath)
if err != nil { if err != nil {
// Log error but continue
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err) fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
} else { } else {
result.Spectrum = spectrum result.Spectrum = spectrum
// Calculate dynamic range, peak, and RMS from decoded samples
calculateRealAudioMetrics(result, filepath) calculateRealAudioMetrics(result, filepath)
} }
// Set bit depth
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample) result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil return result, nil
} }
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) { func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
// Decode FLAC to get actual samples
samples, err := decodeFLACForMetrics(filepath) samples, err := decodeFLACForMetrics(filepath)
if err != nil { if err != nil {
return return
} }
// Calculate peak amplitude
var peak float64 var peak float64
var sumSquares float64 var sumSquares float64
@@ -124,20 +107,16 @@ func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
sumSquares += sample * sample sumSquares += sample * sample
} }
// Convert peak to dB (reference: 1.0 = 0 dBFS)
peakDB := 20.0 * math.Log10(peak) peakDB := 20.0 * math.Log10(peak)
result.PeakAmplitude = peakDB result.PeakAmplitude = peakDB
// Calculate RMS (Root Mean Square)
rms := math.Sqrt(sumSquares / float64(len(samples))) rms := math.Sqrt(sumSquares / float64(len(samples)))
rmsDB := 20.0 * math.Log10(rms) rmsDB := 20.0 * math.Log10(rms)
result.RMSLevel = rmsDB result.RMSLevel = rmsDB
// Dynamic range is the difference between peak and RMS
result.DynamicRange = peakDB - rmsDB result.DynamicRange = peakDB - rmsDB
} }
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
func decodeFLACForMetrics(filepath string) ([]float64, error) { func decodeFLACForMetrics(filepath string) ([]float64, error) {
stream, err := mewflac.ParseFile(filepath) stream, err := mewflac.ParseFile(filepath)
if err != nil { if err != nil {
@@ -145,24 +124,20 @@ func decodeFLACForMetrics(filepath string) ([]float64, error) {
} }
defer stream.Close() defer stream.Close()
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
maxSamples := 10000000 maxSamples := 10000000
samples := make([]float64, 0, maxSamples) samples := make([]float64, 0, maxSamples)
// Read all audio frames
for { for {
frame, err := stream.ParseNext() frame, err := stream.ParseNext()
if err != nil { if err != nil {
break break
} }
// Get samples from first channel (mono or left channel)
var channelSamples []int32 var channelSamples []int32
if len(frame.Subframes) > 0 { if len(frame.Subframes) > 0 {
channelSamples = frame.Subframes[0].Samples channelSamples = frame.Subframes[0].Samples
} }
// Normalize samples to -1.0 to 1.0 range
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1)) maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
for _, sample := range channelSamples { for _, sample := range channelSamples {
if len(samples) >= maxSamples { if len(samples) >= maxSamples {
+2 -3
View File
@@ -6,13 +6,12 @@ import (
) )
func GetDefaultMusicPath() string { func GetDefaultMusicPath() string {
// Get user's home directory
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
// Fallback to Public Music if can't get home dir
return "C:\\Users\\Public\\Music" return "C:\\Users\\Public\\Music"
} }
// Return path to user's Music folder
return filepath.Join(homeDir, "Music") return filepath.Join(homeDir, "Music")
} }
+325 -46
View File
@@ -12,23 +12,24 @@ import (
) )
const ( const (
// Spotify image size codes spotifySize640 = "ab67616d0000b273"
spotifySize640 = "ab67616d0000b273" // 640x640 spotifySizeMax = "ab67616d000082c1"
spotifySizeMax = "ab67616d000082c1" // Max resolution
) )
// CoverDownloadRequest represents a request to download cover art
type CoverDownloadRequest struct { type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
DiscNumber int `json:"disc_number"`
} }
// CoverDownloadResponse represents the response from cover download
type CoverDownloadResponse struct { type CoverDownloadResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
@@ -37,90 +38,103 @@ type CoverDownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"` 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 { type CoverClient struct {
httpClient *http.Client httpClient *http.Client
} }
// NewCoverClient creates a new cover client
func NewCoverClient() *CoverClient { func NewCoverClient() *CoverClient {
return &CoverClient{ return &CoverClient{
httpClient: &http.Client{Timeout: 30 * time.Second}, 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 {
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
safeTitle := sanitizeFilename(trackName) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") { if strings.Contains(filenameFormat, "{") {
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
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 { if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else { } else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
} }
} else { } else {
// Legacy format support
switch filenameFormat { switch filenameFormat {
case "artist-title": case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title-artist":
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
case "title": case "title":
filename = safeTitle filename = safeTitle
default: // "title-artist" default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
} }
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 { 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 func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
// Falls back to original URL if max resolution is not available if strings.Contains(imageURL, spotifySize640) {
func (c *CoverClient) getMaxResolutionURL(coverURL string) string { return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1)
// 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
}
} }
// Return original URL as fallback return imageURL
return coverURL
} }
// 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 { func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
if coverURL == "" { if coverURL == "" {
return fmt.Errorf("cover URL is required") return fmt.Errorf("cover URL is required")
} }
// Use max quality URL if setting is enabled
downloadURL := coverURL downloadURL := coverURL
if embedMaxQualityCover { if embedMaxQualityCover {
downloadURL = c.getMaxResolutionURL(coverURL) downloadURL = c.getMaxResolutionURL(coverURL)
} }
// Download cover image
resp, err := c.httpClient.Get(downloadURL) resp, err := c.httpClient.Get(downloadURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to download cover: %v", err) return fmt.Errorf("failed to download cover: %v", err)
@@ -131,14 +145,12 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode) return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
} }
// Create file
file, err := os.Create(outputPath) file, err := os.Create(outputPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %v", err) return fmt.Errorf("failed to create file: %v", err)
} }
defer file.Close() defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body) _, err = io.Copy(file, resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("failed to write cover file: %v", err) return fmt.Errorf("failed to write cover file: %v", err)
@@ -147,7 +159,6 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
return nil return nil
} }
// DownloadCover downloads cover art for a single track
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) { func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
if req.CoverURL == "" { if req.CoverURL == "" {
return &CoverDownloadResponse{ return &CoverDownloadResponse{
@@ -156,7 +167,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, fmt.Errorf("cover URL is required") }, fmt.Errorf("cover URL is required")
} }
// Create output directory if it doesn't exist
outputDir := req.OutputDir outputDir := req.OutputDir
if outputDir == "" { if outputDir == "" {
outputDir = GetDefaultMusicPath() outputDir = GetDefaultMusicPath()
@@ -171,15 +181,13 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, err }, err
} }
// Generate filename using same format as track
filenameFormat := req.FilenameFormat filenameFormat := req.FilenameFormat
if filenameFormat == "" { if filenameFormat == "" {
filenameFormat = "title-artist" // default filenameFormat = "title-artist"
} }
filename := buildCoverFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position) filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename) filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &CoverDownloadResponse{ return &CoverDownloadResponse{
Success: true, Success: true,
@@ -189,10 +197,8 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, nil }, nil
} }
// Try to get max resolution URL, fallback to original
downloadURL := c.getMaxResolutionURL(req.CoverURL) downloadURL := c.getMaxResolutionURL(req.CoverURL)
// Download cover image
resp, err := c.httpClient.Get(downloadURL) resp, err := c.httpClient.Get(downloadURL)
if err != nil { if err != nil {
return &CoverDownloadResponse{ return &CoverDownloadResponse{
@@ -209,7 +215,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, fmt.Errorf("HTTP %d", resp.StatusCode) }, fmt.Errorf("HTTP %d", resp.StatusCode)
} }
// Create file
file, err := os.Create(filePath) file, err := os.Create(filePath)
if err != nil { if err != nil {
return &CoverDownloadResponse{ return &CoverDownloadResponse{
@@ -219,7 +224,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
} }
defer file.Close() defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body) _, err = io.Copy(file, resp.Body)
if err != nil { if err != nil {
return &CoverDownloadResponse{ return &CoverDownloadResponse{
@@ -234,3 +238,278 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
File: filePath, File: filePath,
}, nil }, 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
}
+143 -78
View File
@@ -13,11 +13,11 @@ import (
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
"time"
"github.com/ulikunitz/xz" "github.com/ulikunitz/xz"
) )
// decodeBase64 decodes a base64 encoded string
func decodeBase64(encoded string) (string, error) { func decodeBase64(encoded string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded) decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil { if err != nil {
@@ -26,6 +26,45 @@ func decodeBase64(encoded string) (string, error) {
return string(decoded), nil 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 ( const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA==" ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6" ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
@@ -33,7 +72,6 @@ const (
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA==" ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
) )
// GetFFmpegDir returns the directory where ffmpeg should be stored
func GetFFmpegDir() (string, error) { func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -42,7 +80,6 @@ func GetFFmpegDir() (string, error) {
return filepath.Join(homeDir, ".spotiflac"), nil return filepath.Join(homeDir, ".spotiflac"), nil
} }
// GetFFmpegPath returns the full path to the ffmpeg executable
func GetFFmpegPath() (string, error) { func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir() ffmpegDir, err := GetFFmpegDir()
if err != nil { if err != nil {
@@ -57,7 +94,6 @@ func GetFFmpegPath() (string, error) {
return filepath.Join(ffmpegDir, ffmpegName), nil return filepath.Join(ffmpegDir, ffmpegName), nil
} }
// GetFFprobePath returns the full path to the ffprobe executable in app directory
func GetFFprobePath() (string, error) { func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir() ffmpegDir, err := GetFFmpegDir()
if err != nil { if err != nil {
@@ -77,62 +113,61 @@ func GetFFprobePath() (string, error) {
return "", fmt.Errorf("ffprobe not found in app directory") return "", fmt.Errorf("ffprobe not found in app directory")
} }
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
func IsFFprobeInstalled() (bool, error) { func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath() ffprobePath, err := GetFFprobePath()
if err != nil { if err != nil {
return false, nil return false, nil
} }
// Verify it's executable if err := ValidateExecutable(ffprobePath); err != nil {
return false, nil
}
cmd := exec.Command(ffprobePath, "-version") cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd) setHideWindow(cmd)
err = cmd.Run() err = cmd.Run()
return err == nil, nil return err == nil, nil
} }
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
func IsFFmpegInstalled() (bool, error) { func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath() ffmpegPath, err := GetFFmpegPath()
if err != nil { if err != nil {
return false, err return false, err
} }
_, err = os.Stat(ffmpegPath) if err := ValidateExecutable(ffmpegPath); err != nil {
if os.IsNotExist(err) {
return false, nil return false, nil
} }
if err != nil {
return false, err
}
// Verify it's executable
cmd := exec.Command(ffmpegPath, "-version") cmd := exec.Command(ffmpegPath, "-version")
// Hide console window on Windows
setHideWindow(cmd) setHideWindow(cmd)
err = cmd.Run() err = cmd.Run()
return err == nil, nil return err == nil, nil
} }
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
func DownloadFFmpeg(progressCallback func(int)) error { func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0)
SetDownloadSpeed(0)
SetDownloading(true)
defer SetDownloading(false)
ffmpegDir, err := GetFFmpegDir() ffmpegDir, err := GetFFmpegDir()
if err != nil { if err != nil {
return err return err
} }
// Create directory if it doesn't exist
if err := os.MkdirAll(ffmpegDir, 0755); err != nil { if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
return fmt.Errorf("failed to create ffmpeg directory: %w", err) 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" { if runtime.GOOS == "darwin" {
ffmpegInstalled, _ := IsFFmpegInstalled() ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled() ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled { if !ffmpegInstalled && !ffprobeInstalled {
// Download both
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL) ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL) fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil { 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) return fmt.Errorf("failed to download ffprobe: %w", err)
} }
} else if !ffmpegInstalled { } else if !ffmpegInstalled {
// Only download ffmpeg
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL) ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL) fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil { if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err return err
} }
} else if !ffprobeInstalled { } else if !ffprobeInstalled {
// Only download ffprobe
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL) ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL) fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil { if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
@@ -162,7 +197,6 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil return nil
} }
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
var encodedURL string var encodedURL string
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
@@ -173,7 +207,6 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
} }
// Decode URL
url, err := decodeBase64(encodedURL) url, err := decodeBase64(encodedURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to decode ffmpeg URL: %w", err) return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
@@ -188,9 +221,8 @@ func DownloadFFmpeg(progressCallback func(int)) error {
return nil return nil
} }
// downloadAndExtract downloads a file and extracts it
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error { func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
// Create temporary file for download
tmpFile, err := os.CreateTemp("", "ffmpeg-*") tmpFile, err := os.CreateTemp("", "ffmpeg-*")
if err != nil { if err != nil {
return fmt.Errorf("failed to create temp file: %w", err) 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 os.Remove(tmpFile.Name())
defer tmpFile.Close() defer tmpFile.Close()
// Download the file
resp, err := http.Get(url) resp, err := http.Get(url)
if err != nil { if err != nil {
return fmt.Errorf("failed to download: %w", err) return fmt.Errorf("failed to download: %w", err)
@@ -211,8 +242,16 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
totalSize := resp.ContentLength totalSize := resp.ContentLength
var downloaded int64 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) buf := make([]byte, 32*1024)
for { for {
n, err := resp.Body.Read(buf) 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) return fmt.Errorf("failed to write to temp file: %w", writeErr)
} }
downloaded += int64(n) 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 { if totalSize > 0 && progressCallback != nil {
// Scale progress between progressStart and progressEnd
rawProgress := float64(downloaded) / float64(totalSize) rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart)) scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress) 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 { if err == io.EOF {
break break
@@ -239,16 +312,20 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
tmpFile.Close() 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" { if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
return extractTarXz(tmpFile.Name(), destDir) return extractTarXz(tmpFile.Name(), destDir)
} }
return extractZip(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 { func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath) r, err := zip.OpenReader(zipPath)
if err != nil { if err != nil {
@@ -280,7 +357,7 @@ func extractZip(zipPath, destDir string) error {
destPath = filepath.Join(destDir, ffprobeName) destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true foundFFprobe = true
} else { } else {
// Skip ffplay and other files
continue continue
} }
@@ -308,7 +385,6 @@ func extractZip(zipPath, destDir string) error {
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath) fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
} }
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe { if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive") return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
} }
@@ -323,7 +399,6 @@ func extractZip(zipPath, destDir string) error {
return nil return nil
} }
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
func extractTarXz(tarXzPath, destDir string) error { func extractTarXz(tarXzPath, destDir string) error {
file, err := os.Open(tarXzPath) file, err := os.Open(tarXzPath)
if err != nil { if err != nil {
@@ -366,7 +441,7 @@ func extractTarXz(tarXzPath, destDir string) error {
destPath = filepath.Join(destDir, ffprobeName) destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true foundFFprobe = true
} else { } else {
// Skip ffplay and other files
continue continue
} }
@@ -387,7 +462,6 @@ func extractTarXz(tarXzPath, destDir string) error {
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath) fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
} }
// At least one of ffmpeg or ffprobe should be found
if !foundFFmpeg && !foundFFprobe { if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive") return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
} }
@@ -402,15 +476,13 @@ func extractTarXz(tarXzPath, destDir string) error {
return nil return nil
} }
// ConvertAudioRequest represents a request to convert audio files
type ConvertAudioRequest struct { type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"` InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"` // mp3, m4a OutputFormat string `json:"output_format"`
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" (ignored for ALAC) Bitrate string `json:"bitrate"`
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac" Codec string `json:"codec"`
} }
// ConvertAudioResult represents the result of a single file conversion
type ConvertAudioResult struct { type ConvertAudioResult struct {
InputFile string `json:"input_file"` InputFile string `json:"input_file"`
OutputFile string `json:"output_file"` OutputFile string `json:"output_file"`
@@ -418,13 +490,16 @@ type ConvertAudioResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// ConvertAudio converts audio files using ffmpeg while preserving metadata
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
ffmpegPath, err := GetFFmpegPath() ffmpegPath, err := GetFFmpegPath()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err) 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() installed, err := IsFFmpegInstalled()
if err != nil || !installed { if err != nil || !installed {
return nil, fmt.Errorf("ffmpeg is not installed") return nil, fmt.Errorf("ffmpeg is not installed")
@@ -434,7 +509,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex var mu sync.Mutex
// Convert files in parallel
for i, inputFile := range req.InputFiles { for i, inputFile := range req.InputFiles {
wg.Add(1) wg.Add(1)
go func(idx int, inputFile string) { go func(idx int, inputFile string) {
@@ -444,16 +518,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
InputFile: inputFile, InputFile: inputFile,
} }
// Get input file info
inputExt := strings.ToLower(filepath.Ext(inputFile)) inputExt := strings.ToLower(filepath.Ext(inputFile))
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt) baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
inputDir := filepath.Dir(inputFile) inputDir := filepath.Dir(inputFile)
// Determine output directory: same as input file location + subfolder (MP3 or M4A)
outputFormatUpper := strings.ToUpper(req.OutputFormat) outputFormatUpper := strings.ToUpper(req.OutputFormat)
outputDir := filepath.Join(inputDir, outputFormatUpper) outputDir := filepath.Join(inputDir, outputFormatUpper)
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
result.Error = fmt.Sprintf("failed to create output directory: %v", err) result.Error = fmt.Sprintf("failed to create output directory: %v", err)
result.Success = false result.Success = false
@@ -463,11 +534,9 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
return return
} }
// Determine output path
outputExt := "." + strings.ToLower(req.OutputFormat) outputExt := "." + strings.ToLower(req.OutputFormat)
outputFile := filepath.Join(outputDir, baseName+outputExt) outputFile := filepath.Join(outputDir, baseName+outputExt)
// Skip if same format
if inputExt == outputExt { if inputExt == outputExt {
result.Error = "Input and output formats are the same" result.Error = "Input and output formats are the same"
result.Success = false result.Success = false
@@ -479,9 +548,14 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
result.OutputFile = outputFile result.OutputFile = outputFile
// Extract cover art and lyrics from input file before conversion
var coverArtPath string var coverArtPath string
var lyrics 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) coverArtPath, _ = ExtractCoverArt(inputFile)
lyrics, err = ExtractLyrics(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) fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
} }
// Build ffmpeg command inputMetadata.Lyrics = lyrics
args := []string{ args := []string{
"-i", inputFile, "-i", inputFile,
"-y", // Overwrite output "-y",
} }
// Add codec and bitrate based on output format
switch req.OutputFormat { switch req.OutputFormat {
case "mp3": case "mp3":
args = append(args, args = append(args,
"-codec:a", "libmp3lame", "-codec:a", "libmp3lame",
"-b:a", req.Bitrate, "-b:a", req.Bitrate,
"-map", "0:a", // Map audio stream "-map", "0:a",
"-map_metadata", "0", // Copy all metadata "-id3v2_version", "3",
"-id3v2_version", "3", // Use ID3v2.3 for better compatibility
) )
// Map video stream if exists (for cover art)
args = append(args, "-map", "0:v?", "-c:v", "copy")
case "m4a": case "m4a":
// Determine codec: ALAC (lossless) or AAC (lossy)
codec := req.Codec codec := req.Codec
if codec == "" { if codec == "" {
codec = "aac" // Default to AAC for backward compatibility codec = "aac"
} }
if codec == "alac" { if codec == "alac" {
// ALAC - Apple Lossless (no bitrate needed)
args = append(args, args = append(args,
"-codec:a", "alac", "-codec:a", "alac",
"-map", "0:a", // Map audio stream "-map", "0:a",
"-map_metadata", "0", // Copy all metadata
) )
} else { } else {
// AAC - lossy with bitrate
args = append(args, args = append(args,
"-codec:a", "aac", "-codec:a", "aac",
"-b:a", req.Bitrate, "-b:a", req.Bitrate,
"-map", "0:a", // Map audio stream "-map", "0:a",
"-map_metadata", "0", // Copy all metadata
) )
} }
// 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) args = append(args, outputFile)
@@ -543,7 +610,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile) fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
cmd := exec.Command(ffmpegPath, args...) cmd := exec.Command(ffmpegPath, args...)
// Hide console window on Windows
setHideWindow(cmd) setHideWindow(cmd)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@@ -552,21 +619,17 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
mu.Lock() mu.Lock()
results[idx] = result results[idx] = result
mu.Unlock() mu.Unlock()
// Clean up temp cover art file if exists
if coverArtPath != "" { if coverArtPath != "" {
os.Remove(coverArtPath) os.Remove(coverArtPath)
} }
return return
} }
// Embed cover art and lyrics after conversion if they were extracted if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
if coverArtPath != "" { fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil { } else {
fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err) fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
} else {
fmt.Printf("[FFmpeg] Cover art embedded successfully\n")
}
os.Remove(coverArtPath) // Clean up temp file
} }
if lyrics != "" { if lyrics != "" {
@@ -577,6 +640,10 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
} }
} }
if coverArtPath != "" {
os.Remove(coverArtPath)
}
result.Success = true result.Success = true
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile) fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
@@ -590,7 +657,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
return results, nil return results, nil
} }
// GetAudioInfo returns information about an audio file
type AudioFileInfo struct { type AudioFileInfo struct {
Path string `json:"path"` Path string `json:"path"`
Filename string `json:"filename"` Filename string `json:"filename"`
@@ -598,7 +664,6 @@ type AudioFileInfo struct {
Size int64 `json:"size"` Size int64 `json:"size"`
} }
// GetAudioFileInfo gets information about an audio file
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) { func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
info, err := os.Stat(filePath) info, err := os.Stat(filePath)
if err != nil { if err != nil {
+1 -3
View File
@@ -7,8 +7,6 @@ import (
"os/exec" "os/exec"
) )
// setHideWindow is a no-op on non-Windows platforms
func setHideWindow(cmd *exec.Cmd) { func setHideWindow(cmd *exec.Cmd) {
// No-op on Unix-like systems
}
}
-2
View File
@@ -8,10 +8,8 @@ import (
"syscall" "syscall"
) )
// setHideWindow sets HideWindow attribute for Windows processes
func setHideWindow(cmd *exec.Cmd) { func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true, HideWindow: true,
} }
} }
-3
View File
@@ -6,7 +6,6 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
// SelectMultipleFiles opens a file dialog to select multiple audio files
func SelectMultipleFiles(ctx context.Context) ([]string, error) { func SelectMultipleFiles(ctx context.Context) ([]string, error) {
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{ files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Audio Files", Title: "Select Audio Files",
@@ -39,7 +38,6 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
return files, nil return files, nil
} }
// SelectOutputDirectory opens a directory dialog to select output folder
func SelectOutputDirectory(ctx context.Context) (string, error) { func SelectOutputDirectory(ctx context.Context) (string, error) {
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{ dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Output Directory", Title: "Select Output Directory",
@@ -49,4 +47,3 @@ func SelectOutputDirectory(ctx context.Context) (string, error) {
} }
return dir, nil return dir, nil
} }
+13 -38
View File
@@ -14,7 +14,6 @@ import (
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac"
) )
// FileInfo represents information about a file or folder
type FileInfo struct { type FileInfo struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
@@ -23,7 +22,6 @@ type FileInfo struct {
Children []FileInfo `json:"children,omitempty"` Children []FileInfo `json:"children,omitempty"`
} }
// AudioMetadata represents metadata read from an audio file
type AudioMetadata struct { type AudioMetadata struct {
Title string `json:"title"` Title string `json:"title"`
Artist string `json:"artist"` Artist string `json:"artist"`
@@ -34,7 +32,6 @@ type AudioMetadata struct {
Year string `json:"year"` Year string `json:"year"`
} }
// RenamePreview represents a preview of file rename operation
type RenamePreview struct { type RenamePreview struct {
OldPath string `json:"old_path"` OldPath string `json:"old_path"`
OldName string `json:"old_name"` OldName string `json:"old_name"`
@@ -44,7 +41,6 @@ type RenamePreview struct {
Metadata AudioMetadata `json:"metadata"` Metadata AudioMetadata `json:"metadata"`
} }
// RenameResult represents the result of a rename operation
type RenameResult struct { type RenameResult struct {
OldPath string `json:"old_path"` OldPath string `json:"old_path"`
NewPath string `json:"new_path"` NewPath string `json:"new_path"`
@@ -52,7 +48,6 @@ type RenameResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// ListDirectory lists files and folders in a directory
func ListDirectory(dirPath string) ([]FileInfo, error) { func ListDirectory(dirPath string) ([]FileInfo, error) {
entries, err := os.ReadDir(dirPath) entries, err := os.ReadDir(dirPath)
if err != nil { if err != nil {
@@ -73,7 +68,6 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
Size: info.Size(), Size: info.Size(),
} }
// If it's a directory, recursively list its contents
if entry.IsDir() { if entry.IsDir() {
children, err := ListDirectory(fileInfo.Path) children, err := ListDirectory(fileInfo.Path)
if err == nil { if err == nil {
@@ -87,13 +81,12 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
return result, nil return result, nil
} }
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
func ListAudioFiles(dirPath string) ([]FileInfo, error) { func ListAudioFiles(dirPath string) ([]FileInfo, error) {
var result []FileInfo var result []FileInfo
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return nil // Skip files with errors return nil
} }
if info.IsDir() { if info.IsDir() {
@@ -120,7 +113,6 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
return result, nil return result, nil
} }
// ReadAudioMetadata reads metadata from an audio file
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) { func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
if !fileExists(filePath) { if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist") 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) { func readFlacMetadata(filePath string) (*AudioMetadata, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -192,7 +183,6 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) {
return metadata, nil return metadata, nil
} }
// readMp3Metadata reads metadata from an MP3 file
func readMp3Metadata(filePath string) (*AudioMetadata, error) { func readMp3Metadata(filePath string) (*AudioMetadata, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
@@ -207,14 +197,12 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
Year: tag.Year(), Year: tag.Year(),
} }
// Get Album Artist (TPE2)
if frames := tag.GetFrames("TPE2"); len(frames) > 0 { if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok { if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
metadata.AlbumArtist = textFrame.Text metadata.AlbumArtist = textFrame.Text
} }
} }
// Get Track Number
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 { if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok { if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
trackStr := strings.Split(textFrame.Text, "/")[0] 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 frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok { if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
discStr := strings.Split(textFrame.Text, "/")[0] discStr := strings.Split(textFrame.Text, "/")[0]
@@ -237,14 +224,16 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
return metadata, nil return metadata, nil
} }
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
ffprobePath, err := GetFFprobePath() ffprobePath, err := GetFFprobePath()
if err != nil { if err != nil {
return nil, err 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, cmd := exec.Command(ffprobePath,
"-v", "quiet", "-v", "quiet",
"-print_format", "json", "-print_format", "json",
@@ -253,7 +242,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
filePath, filePath,
) )
// Hide console window on Windows
setHideWindow(cmd) setHideWindow(cmd)
output, err := cmd.Output() output, err := cmd.Output()
@@ -261,7 +249,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
return nil, err return nil, err
} }
// Parse JSON output
var result struct { var result struct {
Format struct { Format struct {
Tags map[string]string `json:"tags"` Tags map[string]string `json:"tags"`
@@ -277,22 +264,18 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
metadata := &AudioMetadata{} metadata := &AudioMetadata{}
// Merge tags from format and streams (format tags take priority)
allTags := make(map[string]string) allTags := make(map[string]string)
// First add stream tags
for _, stream := range result.Streams { for _, stream := range result.Streams {
for key, value := range stream.Tags { for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value allTags[strings.ToLower(key)] = value
} }
} }
// Then add format tags (overwrite stream tags)
for key, value := range result.Format.Tags { for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value allTags[strings.ToLower(key)] = value
} }
// Parse tags
for key, value := range allTags { for key, value := range allTags {
switch key { switch key {
case "title": case "title":
@@ -304,7 +287,7 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
case "album_artist", "albumartist": case "album_artist", "albumartist":
metadata.AlbumArtist = value metadata.AlbumArtist = value
case "track": case "track":
// Format might be "4" or "4/12"
trackStr := strings.Split(value, "/")[0] trackStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil { if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num metadata.TrackNumber = num
@@ -324,7 +307,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
return metadata, nil return metadata, nil
} }
// readM4aMetadata reads metadata from an M4A file using ffprobe
func readM4aMetadata(filePath string) (*AudioMetadata, error) { func readM4aMetadata(filePath string) (*AudioMetadata, error) {
metadata, err := readMetadataWithFFprobe(filePath) metadata, err := readMetadataWithFFprobe(filePath)
if err != nil { if err != nil {
@@ -333,7 +315,6 @@ func readM4aMetadata(filePath string) (*AudioMetadata, error) {
return metadata, nil return metadata, nil
} }
// GenerateFilename generates a new filename based on metadata and format template
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string { func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
if metadata == nil { if metadata == nil {
return "" return ""
@@ -341,32 +322,32 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
result := format result := format
// Replace placeholders year := metadata.Year
if len(year) >= 4 {
year = year[:4]
}
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title)) result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist)) result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album)) result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist)) result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(metadata.Year)) result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
// Track number with padding
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber)) result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
} else { } else {
result = strings.ReplaceAll(result, "{track}", "") result = strings.ReplaceAll(result, "{track}", "")
} }
// Disc number
if metadata.DiscNumber > 0 { if metadata.DiscNumber > 0 {
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber)) result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
} else { } else {
result = strings.ReplaceAll(result, "{disc}", "") result = strings.ReplaceAll(result, "{disc}", "")
} }
// Clean up multiple spaces and trim
result = strings.TrimSpace(result) result = strings.TrimSpace(result)
result = strings.Join(strings.Fields(result), " ") result = strings.Join(strings.Fields(result), " ")
// Remove leading/trailing separators
result = strings.Trim(result, " -._") result = strings.Trim(result, " -._")
if result == "" { if result == "" {
@@ -376,9 +357,8 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
return result + ext return result + ext
} }
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
func sanitizeFilenameForRename(name string) string { func sanitizeFilenameForRename(name string) string {
// Remove characters that are invalid in filenames
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"} invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
result := name result := name
for _, char := range invalid { for _, char := range invalid {
@@ -387,7 +367,6 @@ func sanitizeFilenameForRename(name string) string {
return strings.TrimSpace(result) return strings.TrimSpace(result)
} }
// PreviewRename generates a preview of rename operations
func PreviewRename(files []string, format string) []RenamePreview { func PreviewRename(files []string, format string) []RenamePreview {
var previews []RenamePreview var previews []RenamePreview
@@ -424,7 +403,6 @@ func PreviewRename(files []string, format string) []RenamePreview {
return previews return previews
} }
// GetFileSizes returns file sizes for a list of file paths
func GetFileSizes(files []string) map[string]int64 { func GetFileSizes(files []string) map[string]int64 {
result := make(map[string]int64) result := make(map[string]int64)
for _, filePath := range files { for _, filePath := range files {
@@ -436,7 +414,6 @@ func GetFileSizes(files []string) map[string]int64 {
return result return result
} }
// RenameFiles renames files based on their metadata
func RenameFiles(files []string, format string) []RenameResult { func RenameFiles(files []string, format string) []RenameResult {
var results []RenameResult var results []RenameResult
@@ -466,7 +443,6 @@ func RenameFiles(files []string, format string) []RenameResult {
newPath := filepath.Join(filepath.Dir(filePath), newName) newPath := filepath.Join(filepath.Dir(filePath), newName)
result.NewPath = newPath result.NewPath = newPath
// Check if new path already exists (and is different from old path)
if newPath != filePath { if newPath != filePath {
if _, err := os.Stat(newPath); err == nil { if _, err := os.Stat(newPath); err == nil {
result.Error = "File already exists" result.Error = "File already exists"
@@ -476,7 +452,6 @@ func RenameFiles(files []string, format string) []RenameResult {
} }
} }
// Rename the file
if err := os.Rename(filePath, newPath); err != nil { if err := os.Rename(filePath, newPath); err != nil {
result.Error = err.Error() result.Error = err.Error()
result.Success = false result.Success = false
+30 -47
View File
@@ -9,41 +9,53 @@ import (
"unicode/utf8" "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 {
func BuildExpectedFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") { if strings.Contains(filenameFormat, "{") {
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
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 { if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else { } else {
// Remove {track} with common separators like ". " or " - " or ". "
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
} }
} else { } else {
// Legacy format support
switch filenameFormat { switch filenameFormat {
case "artist-title": case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title": case "title":
filename = safeTitle filename = safeTitle
default: // "title-artist" default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
} }
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 { if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename) filename = fmt.Sprintf("%02d. %s", position, filename)
} }
@@ -52,109 +64,81 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
return filename + ".flac" return filename + ".flac"
} }
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(name string) string { func sanitizeFilename(name string) string {
// Replace forward slash with space (more natural than underscore)
sanitized := strings.ReplaceAll(name, "/", " ") sanitized := strings.ReplaceAll(name, "/", " ")
// Remove other invalid filesystem characters (replace with space)
re := regexp.MustCompile(`[<>:"\\|?*]`) re := regexp.MustCompile(`[<>:"\\|?*]`)
sanitized = re.ReplaceAllString(sanitized, " ") sanitized = re.ReplaceAllString(sanitized, " ")
// Remove control characters (0x00-0x1F, 0x7F)
var result strings.Builder var result strings.Builder
for _, r := range sanitized { 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 { if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue continue
} }
if r == 0x7F { if r == 0x7F {
continue 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 { if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue 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) result.WriteRune(r)
} }
sanitized = result.String() sanitized = result.String()
sanitized = strings.TrimSpace(sanitized) sanitized = strings.TrimSpace(sanitized)
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
sanitized = strings.Trim(sanitized, ". ") sanitized = strings.Trim(sanitized, ". ")
// Normalize consecutive spaces to single space
re = regexp.MustCompile(`\s+`) re = regexp.MustCompile(`\s+`)
sanitized = re.ReplaceAllString(sanitized, " ") sanitized = re.ReplaceAllString(sanitized, " ")
// Normalize consecutive underscores to single underscore
re = regexp.MustCompile(`_+`) re = regexp.MustCompile(`_+`)
sanitized = re.ReplaceAllString(sanitized, "_") sanitized = re.ReplaceAllString(sanitized, "_")
// Remove leading/trailing underscores and spaces
sanitized = strings.Trim(sanitized, "_ ") sanitized = strings.Trim(sanitized, "_ ")
if sanitized == "" { if sanitized == "" {
return "Unknown" return "Unknown"
} }
// Ensure the result is valid UTF-8
if !utf8.ValidString(sanitized) { if !utf8.ValidString(sanitized) {
// If invalid UTF-8, try to fix it
sanitized = strings.ToValidUTF8(sanitized, "_") sanitized = strings.ToValidUTF8(sanitized, "_")
} }
return 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 { func NormalizePath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator)) 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 { func SanitizeFolderPath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator)) normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
// Detect separator
sep := string(filepath.Separator) sep := string(filepath.Separator)
// Split path into components
parts := strings.Split(normalizedPath, sep) parts := strings.Split(normalizedPath, sep)
sanitizedParts := make([]string, 0, len(parts)) sanitizedParts := make([]string, 0, len(parts))
for i, part := range parts { for i, part := range parts {
// Keep drive letter intact on Windows (e.g., "C:")
if i == 0 && len(part) == 2 && part[1] == ':' { if i == 0 && len(part) == 2 && part[1] == ':' {
sanitizedParts = append(sanitizedParts, part) sanitizedParts = append(sanitizedParts, part)
continue continue
} }
// Keep empty first part for absolute paths on Unix (e.g., "/Users/...")
if i == 0 && part == "" { if i == 0 && part == "" {
sanitizedParts = append(sanitizedParts, part) sanitizedParts = append(sanitizedParts, part)
continue continue
} }
// Sanitize each folder name (but don't replace / or \ since we already normalized)
sanitized := sanitizeFolderName(part) sanitized := sanitizeFolderName(part)
if sanitized != "" { if sanitized != "" {
sanitizedParts = append(sanitizedParts, sanitized) sanitizedParts = append(sanitizedParts, sanitized)
@@ -164,8 +148,7 @@ func SanitizeFolderPath(folderPath string) string {
return strings.Join(sanitizedParts, sep) return strings.Join(sanitizedParts, sep)
} }
// sanitizeFolderName removes invalid characters from a single folder name
func sanitizeFolderName(name string) string { func sanitizeFolderName(name string) string {
// Use the same sanitization as filename
return sanitizeFilename(name) return sanitizeFilename(name)
} }
+2 -4
View File
@@ -14,7 +14,7 @@ func OpenFolderInExplorer(path string) error {
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
cmd = exec.Command("explorer", path) cmd = exec.Command("explorer", path)
case "darwin": // macOS case "darwin":
cmd = exec.Command("open", path) cmd = exec.Command("open", path)
case "linux": case "linux":
cmd = exec.Command("xdg-open", path) cmd = exec.Command("xdg-open", path)
@@ -26,7 +26,7 @@ func OpenFolderInExplorer(path string) error {
} }
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) { func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
// If defaultPath is empty, use default music path
if defaultPath == "" { if defaultPath == "" {
defaultPath = GetDefaultMusicPath() defaultPath = GetDefaultMusicPath()
} }
@@ -41,7 +41,6 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
return "", err return "", err
} }
// If user cancelled, selectedPath will be empty
if selectedPath == "" { if selectedPath == "" {
return "", nil return "", nil
} }
@@ -69,7 +68,6 @@ func SelectFileDialog(ctx context.Context) (string, error) {
return "", err return "", err
} }
// If user cancelled, selectedFile will be empty
if selectedFile == "" { if selectedFile == "" {
return "", nil return "", nil
} }
+99 -57
View File
@@ -14,7 +14,6 @@ import (
"time" "time"
) )
// LRCLibResponse represents the LRCLIB API response
type LRCLibResponse struct { type LRCLibResponse struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -27,33 +26,33 @@ type LRCLibResponse struct {
SyncedLyrics string `json:"syncedLyrics"` SyncedLyrics string `json:"syncedLyrics"`
} }
// LyricsLine represents a single line of lyrics
type LyricsLine struct { type LyricsLine struct {
StartTimeMs string `json:"startTimeMs"` StartTimeMs string `json:"startTimeMs"`
Words string `json:"words"` Words string `json:"words"`
EndTimeMs string `json:"endTimeMs"` EndTimeMs string `json:"endTimeMs"`
} }
// LyricsResponse represents the API response
type LyricsResponse struct { type LyricsResponse struct {
Error bool `json:"error"` Error bool `json:"error"`
SyncType string `json:"syncType"` SyncType string `json:"syncType"`
Lines []LyricsLine `json:"lines"` Lines []LyricsLine `json:"lines"`
} }
// LyricsDownloadRequest represents a request to download lyrics
type LyricsDownloadRequest struct { type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
ReleaseDate string `json:"release_date"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"` TrackNumber bool `json:"track_number"`
Position int `json:"position"` Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"` UseAlbumTrackNumber bool `json:"use_album_track_number"`
DiscNumber int `json:"disc_number"`
} }
// LyricsDownloadResponse represents the response from lyrics download
type LyricsDownloadResponse struct { type LyricsDownloadResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
@@ -62,27 +61,28 @@ type LyricsDownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"`
} }
// LyricsClient handles lyrics fetching
type LyricsClient struct { type LyricsClient struct {
httpClient *http.Client httpClient *http.Client
} }
// NewLyricsClient creates a new lyrics client
func NewLyricsClient() *LyricsClient { func NewLyricsClient() *LyricsClient {
return &LyricsClient{ return &LyricsClient{
httpClient: &http.Client{Timeout: 15 * time.Second}, httpClient: &http.Client{Timeout: 15 * time.Second},
} }
} }
// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) {
// Try LRCLIB API
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
apiURL := fmt.Sprintf("%s%s&track_name=%s", apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase), string(apiBase),
url.QueryEscape(artistName), url.QueryEscape(artistName),
url.QueryEscape(trackName)) url.QueryEscape(trackName))
if duration > 0 {
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
}
resp, err := c.httpClient.Get(apiURL) resp, err := c.httpClient.Get(apiURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err) return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
@@ -103,11 +103,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*L
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err) return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
} }
// Convert LRCLIB response to our LyricsResponse format
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
} }
// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse { func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
resp := &LyricsResponse{ resp := &LyricsResponse{
Error: false, Error: false,
@@ -115,7 +113,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
Lines: []LyricsLine{}, Lines: []LyricsLine{},
} }
// Prefer synced lyrics, fall back to plain
lyricsText := lrcLib.SyncedLyrics lyricsText := lrcLib.SyncedLyrics
if lyricsText == "" { if lyricsText == "" {
lyricsText = lrcLib.PlainLyrics lyricsText = lrcLib.PlainLyrics
@@ -127,7 +124,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
return resp return resp
} }
// Parse synced lyrics format [mm:ss.xx] text
lines := strings.Split(lyricsText, "\n") lines := strings.Split(lyricsText, "\n")
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@@ -135,14 +131,12 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
continue continue
} }
// Check if line has timestamp [mm:ss.xx]
if strings.HasPrefix(line, "[") && len(line) > 10 { if strings.HasPrefix(line, "[") && len(line) > 10 {
closeBracket := strings.Index(line, "]") closeBracket := strings.Index(line, "]")
if closeBracket > 0 { if closeBracket > 0 {
timestamp := line[1:closeBracket] timestamp := line[1:closeBracket]
words := strings.TrimSpace(line[closeBracket+1:]) words := strings.TrimSpace(line[closeBracket+1:])
// Convert [mm:ss.xx] to milliseconds
ms := lrcTimestampToMs(timestamp) ms := lrcTimestampToMs(timestamp)
resp.Lines = append(resp.Lines, LyricsLine{ resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms), StartTimeMs: fmt.Sprintf("%d", ms),
@@ -152,9 +146,8 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
} }
} }
// Plain lyrics line (no timestamp)
resp.Lines = append(resp.Lines, LyricsLine{ resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: "0", StartTimeMs: "",
Words: line, Words: line,
}) })
} }
@@ -162,10 +155,9 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
return resp return resp
} }
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
func lrcTimestampToMs(timestamp string) int64 { func lrcTimestampToMs(timestamp string) int64 {
var minutes, seconds, centiseconds int64 var minutes, seconds, centiseconds int64
// Try parsing mm:ss.xx format
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds) n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 { if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10 return minutes*60*1000 + seconds*1000 + centiseconds*10
@@ -173,7 +165,6 @@ func lrcTimestampToMs(timestamp string) int64 {
return 0 return 0
} }
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName) query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
@@ -203,7 +194,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return nil, fmt.Errorf("no results found") return nil, fmt.Errorf("no results found")
} }
// Find best match - prefer one with synced lyrics
var best *LRCLibResponse var best *LRCLibResponse
for i := range results { for i := range results {
if results[i].SyncedLyrics != "" { if results[i].SyncedLyrics != "" {
@@ -222,41 +212,37 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
return c.convertLRCLibToLyricsResponse(best), nil return c.convertLRCLibToLyricsResponse(best), nil
} }
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
func simplifyTrackName(name string) string { func simplifyTrackName(name string) string {
// Remove content in parentheses
if idx := strings.Index(name, "("); idx > 0 { if idx := strings.Index(name, "("); idx > 0 {
name = strings.TrimSpace(name[:idx]) name = strings.TrimSpace(name[:idx])
} }
// Remove content after " - " (like "From the Motion Picture")
if idx := strings.Index(name, " - "); idx > 0 { if idx := strings.Index(name, " - "); idx > 0 {
name = strings.TrimSpace(name[:idx]) name = strings.TrimSpace(name[:idx])
} }
return name return name
} }
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
// 1. Try LRCLIB exact match resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil return resp, "LRCLIB", nil
} }
fmt.Printf(" LRCLIB exact: %v\n", err) fmt.Printf(" LRCLIB exact: %v\n", err)
// 2. Try LRCLIB search
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName) resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search", nil return resp, "LRCLIB Search", nil
} }
fmt.Printf(" LRCLIB search: %v\n", err) fmt.Printf(" LRCLIB search: %v\n", err)
// 3. Try with simplified track name (remove parentheses, subtitles)
simplifiedTrack := simplifyTrackName(trackName) simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName { if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack) 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 { if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB (simplified)", nil return resp, "LRCLIB (simplified)", nil
} }
@@ -270,31 +256,31 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return nil, "", fmt.Errorf("lyrics not found in any source") 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 { func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
var sb strings.Builder var sb strings.Builder
// Add metadata
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
sb.WriteString("[by:SpotiFlac]\n") sb.WriteString("[by:SpotiFlac]\n")
sb.WriteString("\n") sb.WriteString("\n")
// Add lyrics lines
for _, line := range lyrics.Lines { for _, line := range lyrics.Lines {
if line.Words == "" { if line.Words == "" {
continue continue
} }
// Convert milliseconds to LRC timestamp format [mm:ss.xx] if line.StartTimeMs == "" {
timestamp := msToLRCTimestamp(line.StartTimeMs) sb.WriteString(fmt.Sprintf("%s\n", line.Words))
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words)) } else {
timestamp := msToLRCTimestamp(line.StartTimeMs)
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
}
} }
return sb.String() return sb.String()
} }
// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx]
func msToLRCTimestamp(msStr string) string { func msToLRCTimestamp(msStr string) string {
var ms int64 var ms int64
fmt.Sscanf(msStr, "%d", &ms) fmt.Sscanf(msStr, "%d", &ms)
@@ -307,40 +293,52 @@ func msToLRCTimestamp(msStr string) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) 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 {
func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
safeTitle := sanitizeFilename(trackName) safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName) safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") { if strings.Contains(filenameFormat, "{") {
filename = filenameFormat filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist) filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
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 { if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else { } else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
} }
} else { } else {
// Legacy format support
switch filenameFormat { switch filenameFormat {
case "artist-title": case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title": case "title":
filename = safeTitle filename = safeTitle
default: // "title-artist" default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
} }
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 { if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename) filename = fmt.Sprintf("%02d. %s", position, filename)
} }
@@ -349,7 +347,47 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
return filename + ".lrc" 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) { func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
if req.SpotifyID == "" { if req.SpotifyID == "" {
return &LyricsDownloadResponse{ return &LyricsDownloadResponse{
@@ -358,7 +396,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, fmt.Errorf("spotify ID is required") }, fmt.Errorf("spotify ID is required")
} }
// Create output directory if it doesn't exist
outputDir := req.OutputDir outputDir := req.OutputDir
if outputDir == "" { if outputDir == "" {
outputDir = GetDefaultMusicPath() outputDir = GetDefaultMusicPath()
@@ -373,15 +410,13 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err }, err
} }
// Generate filename using same format as track
filenameFormat := req.FilenameFormat filenameFormat := req.FilenameFormat
if filenameFormat == "" { if filenameFormat == "" {
filenameFormat = "title-artist" // default filenameFormat = "title-artist"
} }
filename := buildLyricsFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position) filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename) filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &LyricsDownloadResponse{ return &LyricsDownloadResponse{
Success: true, Success: true,
@@ -391,8 +426,17 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, nil }, nil
} }
// Fetch lyrics from LRCLIB audioDuration := 0
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) 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 { if err != nil {
return &LyricsDownloadResponse{ return &LyricsDownloadResponse{
Success: false, Success: false,
@@ -400,10 +444,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err }, err
} }
// Convert to LRC format
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName) lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
// Write LRC file
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil { if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
return &LyricsDownloadResponse{ return &LyricsDownloadResponse{
Success: false, Success: false,
+484 -203
View File
@@ -1,13 +1,13 @@
package backend package backend
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
pathfilepath "path/filepath" pathfilepath "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
id3v2 "github.com/bogem/id3v2/v2" id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture"
@@ -20,12 +20,15 @@ type Metadata struct {
Artist string Artist string
Album string Album string
AlbumArtist string AlbumArtist string
Date string // Recorded date (full date YYYY-MM-DD) Date string
ReleaseDate string // Release date (full date) - kept for compatibility ReleaseDate string
TrackNumber int TrackNumber int
TotalTracks int // Total tracks in album TotalTracks int
DiscNumber int DiscNumber int
ISRC string TotalDiscs int
URL string
Copyright string
Publisher string
Lyrics string Lyrics string
Description string Description string
} }
@@ -70,15 +73,21 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.DiscNumber > 0 { if metadata.DiscNumber > 0 {
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) _ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
} }
if metadata.ISRC != "" { if metadata.TotalDiscs > 0 {
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC) _ = 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 != "" { if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description) _ = cmt.Add("DESCRIPTION", metadata.Description)
} }
// Lyrics is added last to keep it at the bottom
if metadata.Lyrics != "" { if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced _ = cmt.Add("LYRICS", metadata.Lyrics)
} }
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
@@ -135,20 +144,17 @@ func fileExists(path string) bool {
return err == nil 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 { func extractYear(releaseDate string) string {
if releaseDate == "" { if releaseDate == "" {
return "" return ""
} }
// Try to extract year (first 4 digits)
if len(releaseDate) >= 4 { if len(releaseDate) >= 4 {
return releaseDate[:4] return releaseDate[:4]
} }
return releaseDate return releaseDate
} }
// EmbedLyricsOnly adds lyrics to a FLAC file while preserving existing metadata
func EmbedLyricsOnly(filepath string, lyrics string) error { func EmbedLyricsOnly(filepath string, lyrics string) error {
if lyrics == "" { if lyrics == "" {
return nil return nil
@@ -171,10 +177,8 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
} }
} }
// Create new comment block, preserving existing comments
cmt := flacvorbis.New() cmt := flacvorbis.New()
// Copy existing comments except LYRICS
if existingCmt != nil { if existingCmt != nil {
for _, comment := range existingCmt.Comments { for _, comment := range existingCmt.Comments {
parts := strings.SplitN(comment, "=", 2) parts := strings.SplitN(comment, "=", 2)
@@ -187,7 +191,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
} }
} }
// Add lyrics
_ = cmt.Add("LYRICS", lyrics) _ = cmt.Add("LYRICS", lyrics)
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
@@ -204,82 +207,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
return nil 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) { func ExtractCoverArt(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath)) 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) { func extractCoverFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
@@ -311,7 +237,6 @@ func extractCoverFromMp3(filePath string) (string, error) {
return "", fmt.Errorf("invalid picture frame") return "", fmt.Errorf("invalid picture frame")
} }
// Create temporary file
tmpFile, err := os.CreateTemp("", "cover-*.jpg") tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err) return "", fmt.Errorf("failed to create temp file: %w", err)
@@ -326,7 +251,6 @@ func extractCoverFromMp3(filePath string) (string, error) {
return tmpFile.Name(), nil return tmpFile.Name(), nil
} }
// extractCoverFromM4AOrFlac extracts cover art from M4A or FLAC file
func extractCoverFromM4AOrFlac(filePath string) (string, error) { func extractCoverFromM4AOrFlac(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath)) ext := strings.ToLower(pathfilepath.Ext(filePath))
@@ -343,7 +267,6 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
continue continue
} }
// Create temporary file
tmpFile, err := os.CreateTemp("", "cover-*.jpg") tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err) 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") 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 return "", nil
} }
// ExtractLyrics extracts lyrics from an audio file
func ExtractLyrics(filePath string) (string, error) { func ExtractLyrics(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath)) ext := strings.ToLower(pathfilepath.Ext(filePath))
@@ -376,14 +296,13 @@ func ExtractLyrics(filePath string) (string, error) {
case ".flac": case ".flac":
return extractLyricsFromFlac(filePath) return extractLyricsFromFlac(filePath)
case ".m4a": case ".m4a":
// M4A lyrics extraction would need different approach
return "", nil return "", nil
default: default:
return "", fmt.Errorf("unsupported file format: %s", ext) return "", fmt.Errorf("unsupported file format: %s", ext)
} }
} }
// extractLyricsFromMp3 extracts lyrics from MP3 file
func extractLyricsFromMp3(filePath string) (string, error) { func extractLyricsFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
@@ -412,7 +331,6 @@ func extractLyricsFromMp3(filePath string) (string, error) {
return uslt.Lyrics, nil return uslt.Lyrics, nil
} }
// extractLyricsFromFlac extracts lyrics from FLAC file
func extractLyricsFromFlac(filePath string) (string, error) { func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
@@ -426,7 +344,6 @@ func extractLyricsFromFlac(filePath string) (string, error) {
continue continue
} }
// Search through comments for lyrics
for _, comment := range cmt.Comments { for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2) parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 { if len(parts) == 2 {
@@ -445,7 +362,6 @@ func extractLyricsFromFlac(filePath string) (string, error) {
return "", nil return "", nil
} }
// EmbedCoverArtOnly embeds cover art into an audio file
func EmbedCoverArtOnly(filePath string, coverPath string) error { func EmbedCoverArtOnly(filePath string, coverPath string) error {
if coverPath == "" || !fileExists(coverPath) { if coverPath == "" || !fileExists(coverPath) {
return nil return nil
@@ -457,16 +373,13 @@ func EmbedCoverArtOnly(filePath string, coverPath string) error {
case ".mp3": case ".mp3":
return embedCoverToMp3(filePath, coverPath) return embedCoverToMp3(filePath, coverPath)
case ".m4a": 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 return nil
default: default:
return fmt.Errorf("unsupported file format: %s", ext) return fmt.Errorf("unsupported file format: %s", ext)
} }
} }
// embedCoverToMp3 embeds cover art into MP3 file
func embedCoverToMp3(filePath string, coverPath string) error { func embedCoverToMp3(filePath string, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
@@ -474,16 +387,13 @@ func embedCoverToMp3(filePath string, coverPath string) error {
} }
defer tag.Close() defer tag.Close()
// Remove existing cover art
tag.DeleteFrames(tag.CommonID("Attached picture")) tag.DeleteFrames(tag.CommonID("Attached picture"))
// Read cover art
artwork, err := os.ReadFile(coverPath) artwork, err := os.ReadFile(coverPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to read cover art: %w", err) return fmt.Errorf("failed to read cover art: %w", err)
} }
// Add new cover art
pic := id3v2.PictureFrame{ pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8, Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg", MimeType: "image/jpeg",
@@ -500,27 +410,30 @@ func embedCoverToMp3(filePath string, coverPath string) error {
return nil return nil
} }
// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error { func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
if lyrics == "" { if lyrics == "" {
return nil 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}) tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err) return fmt.Errorf("failed to open MP3 file: %w", err)
} }
defer tag.Close() defer tag.Close()
// Remove existing USLT frames
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) 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{ usltFrame := id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8, // Use UTF-8 instead of default encoding Encoding: id3v2.EncodingUTF8,
Language: "eng", Language: "eng",
ContentDescriptor: "", // Empty descriptor for better compatibility ContentDescriptor: "",
Lyrics: lyrics, Lyrics: lyrics,
} }
tag.AddUnsynchronisedLyricsFrame(usltFrame) tag.AddUnsynchronisedLyricsFrame(usltFrame)
@@ -532,27 +445,32 @@ func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
return nil return nil
} }
// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg
func embedLyricsToM4A(filepath string, lyrics string) error { 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() ffmpegPath, err := GetFFmpegPath()
if err != nil { if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err) 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) tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
defer func() { defer func() {
// Only remove if file still exists (rename might have failed)
if _, err := os.Stat(tmpOutputFile); err == nil { if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile) 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, cmd := exec.Command(ffmpegPath,
"-i", filepath, "-i", filepath,
"-map", "0", "-map", "0",
@@ -560,12 +478,11 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
"-metadata", "lyrics-eng="+lyrics, "-metadata", "lyrics-eng="+lyrics,
"-metadata", "lyrics="+lyrics, "-metadata", "lyrics="+lyrics,
"-codec", "copy", "-codec", "copy",
"-f", "ipod", // Explicitly specify M4A/iPod format "-f", "ipod",
"-y", // Overwrite "-y",
tmpOutputFile, tmpOutputFile,
) )
// Hide console window on Windows
setHideWindow(cmd) setHideWindow(cmd)
output, err := cmd.CombinedOutput() 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) 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 { if err := os.Rename(tmpOutputFile, filepath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err) return fmt.Errorf("failed to replace original file: %w", err)
} }
@@ -583,7 +499,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
return nil return nil
} }
// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
if lyrics == "" { if lyrics == "" {
return nil return nil
@@ -602,85 +517,451 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
} }
} }
// FileExistenceResult represents the result of checking if a file exists func GetAudioDuration(filepath string) (float64, error) {
type FileExistenceResult struct { ext := strings.ToLower(pathfilepath.Ext(filepath))
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"`
}
// CheckFilesExistParallel checks if multiple files exist in parallel if ext == ".flac" {
// It builds an ISRC index from the output directory once, then checks all tracks against it duration, err := getFlacDuration(filepath)
func CheckFilesExistParallel(outputDir string, tracks []struct { if err == nil && duration > 0 {
ISRC string return duration, nil
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
}
}
results[idx] = result
}(i, track)
} }
wg.Wait() return getDurationWithFFprobe(filepath)
return results
} }
// buildISRCIndex scans a directory and builds a map of ISRC -> file path func getFlacDuration(filepath string) (float64, error) {
func buildISRCIndex(outputDir string) map[string]string { f, err := flac.ParseFile(filepath)
index := make(map[string]string) if err != nil {
return 0, err
}
// Walk directory recursively - only check .flac files for SpotiFLAC if len(f.Meta) > 0 {
pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error { streamInfo := f.Meta[0]
if err != nil || info.IsDir() { if streamInfo.Type == flac.StreamInfo {
return nil 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
}
}
} }
}
ext := strings.ToLower(pathfilepath.Ext(path)) return 0, fmt.Errorf("could not extract duration from FLAC file")
if ext != ".flac" { }
return nil
} func getDurationWithFFprobe(filepath string) (float64, error) {
ffprobePath, err := GetFFprobePath()
// Read ISRC from file if err != nil {
isrc, err := ReadISRCFromFile(path) return 0, err
if err != nil || isrc == "" { }
return nil
} if err := ValidateExecutable(ffprobePath); err != nil {
return 0, fmt.Errorf("invalid ffprobe executable: %w", err)
// Store in index (uppercase for case-insensitive matching) }
index[strings.ToUpper(isrc)] = path
return nil cmd := exec.Command(ffprobePath,
}) "-v", "quiet",
"-print_format", "json",
return index "-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)
}
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)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
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" "time"
) )
// DownloadStatus represents the status of a download item
type DownloadStatus string type DownloadStatus string
const ( const (
@@ -18,7 +17,6 @@ const (
StatusSkipped DownloadStatus = "skipped" StatusSkipped DownloadStatus = "skipped"
) )
// DownloadItem represents a single item in the download queue
type DownloadItem struct { type DownloadItem struct {
ID string `json:"id"` ID string `json:"id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
@@ -26,16 +24,15 @@ type DownloadItem struct {
AlbumName string `json:"album_name"` AlbumName string `json:"album_name"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Status DownloadStatus `json:"status"` Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"` // MB downloaded Progress float64 `json:"progress"`
TotalSize float64 `json:"total_size"` // MB total (if known) TotalSize float64 `json:"total_size"`
Speed float64 `json:"speed"` // MB/s Speed float64 `json:"speed"`
StartTime int64 `json:"start_time"` // Unix timestamp StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"` // Unix timestamp EndTime int64 `json:"end_time"`
ErrorMessage string `json:"error_message"` // If failed ErrorMessage string `json:"error_message"`
FilePath string `json:"file_path"` // Final file path FilePath string `json:"file_path"`
} }
// Global progress tracker
var ( var (
currentProgress float64 currentProgress float64
currentProgressLock sync.RWMutex currentProgressLock sync.RWMutex
@@ -44,7 +41,6 @@ var (
currentSpeed float64 currentSpeed float64
speedLock sync.RWMutex speedLock sync.RWMutex
// Download queue tracking
downloadQueue []DownloadItem downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex downloadQueueLock sync.RWMutex
currentItemID string currentItemID string
@@ -55,27 +51,24 @@ var (
sessionStartLock sync.RWMutex sessionStartLock sync.RWMutex
) )
// ProgressInfo represents download progress information
type ProgressInfo struct { type ProgressInfo struct {
IsDownloading bool `json:"is_downloading"` IsDownloading bool `json:"is_downloading"`
MBDownloaded float64 `json:"mb_downloaded"` MBDownloaded float64 `json:"mb_downloaded"`
SpeedMBps float64 `json:"speed_mbps"` SpeedMBps float64 `json:"speed_mbps"`
} }
// DownloadQueueInfo represents the complete download queue state
type DownloadQueueInfo struct { type DownloadQueueInfo struct {
IsDownloading bool `json:"is_downloading"` IsDownloading bool `json:"is_downloading"`
Queue []DownloadItem `json:"queue"` Queue []DownloadItem `json:"queue"`
CurrentSpeed float64 `json:"current_speed"` // MB/s CurrentSpeed float64 `json:"current_speed"`
TotalDownloaded float64 `json:"total_downloaded"` // MB this session TotalDownloaded float64 `json:"total_downloaded"`
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp SessionStartTime int64 `json:"session_start_time"`
QueuedCount int `json:"queued_count"` QueuedCount int `json:"queued_count"`
CompletedCount int `json:"completed_count"` CompletedCount int `json:"completed_count"`
FailedCount int `json:"failed_count"` FailedCount int `json:"failed_count"`
SkippedCount int `json:"skipped_count"` SkippedCount int `json:"skipped_count"`
} }
// GetDownloadProgress returns current download progress
func GetDownloadProgress() ProgressInfo { func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock() downloadingLock.RLock()
downloading := isDownloading downloading := isDownloading
@@ -96,34 +89,30 @@ func GetDownloadProgress() ProgressInfo {
} }
} }
// SetDownloadSpeed updates the current download speed
func SetDownloadSpeed(mbps float64) { func SetDownloadSpeed(mbps float64) {
speedLock.Lock() speedLock.Lock()
currentSpeed = mbps currentSpeed = mbps
speedLock.Unlock() speedLock.Unlock()
} }
// SetDownloadProgress updates the current download progress
func SetDownloadProgress(mbDownloaded float64) { func SetDownloadProgress(mbDownloaded float64) {
currentProgressLock.Lock() currentProgressLock.Lock()
currentProgress = mbDownloaded currentProgress = mbDownloaded
currentProgressLock.Unlock() currentProgressLock.Unlock()
} }
// SetDownloading sets the downloading state
func SetDownloading(downloading bool) { func SetDownloading(downloading bool) {
downloadingLock.Lock() downloadingLock.Lock()
isDownloading = downloading isDownloading = downloading
downloadingLock.Unlock() downloadingLock.Unlock()
if !downloading { if !downloading {
// Reset progress when download completes
SetDownloadProgress(0) SetDownloadProgress(0)
SetDownloadSpeed(0) SetDownloadSpeed(0)
} }
} }
// ProgressWriter wraps an io.Writer and reports download progress
type ProgressWriter struct { type ProgressWriter struct {
writer io.Writer writer io.Writer
total int64 total int64
@@ -131,7 +120,7 @@ type ProgressWriter struct {
startTime int64 startTime int64
lastTime int64 lastTime int64
lastBytes int64 lastBytes int64
itemID string // Track which download item this belongs to itemID string
} }
func NewProgressWriter(writer io.Writer) *ProgressWriter { 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 { func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
pw := NewProgressWriter(writer) pw := NewProgressWriter(writer)
pw.itemID = itemID pw.itemID = itemID
@@ -162,13 +150,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p) n, err := pw.writer.Write(p)
pw.total += int64(n) pw.total += int64(n)
// Report progress every 256KB for smoother updates
if pw.total-pw.lastPrinted >= 256*1024 { if pw.total-pw.lastPrinted >= 256*1024 {
mbDownloaded := float64(pw.total) / (1024 * 1024) mbDownloaded := float64(pw.total) / (1024 * 1024)
// Calculate speed (MB/s)
now := getCurrentTimeMillis() now := getCurrentTimeMillis()
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds timeDiff := float64(now-pw.lastTime) / 1000.0
bytesDiff := float64(pw.total - pw.lastBytes) bytesDiff := float64(pw.total - pw.lastBytes)
var speedMBps float64 var speedMBps float64
@@ -180,10 +166,8 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded) fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
} }
// Update global progress
SetDownloadProgress(mbDownloaded) SetDownloadProgress(mbDownloaded)
// Update individual item progress if we have an item ID
if pw.itemID != "" { if pw.itemID != "" {
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps) UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
} }
@@ -200,9 +184,6 @@ func (pw *ProgressWriter) GetTotal() int64 {
return pw.total return pw.total
} }
// Queue management functions
// AddToQueue adds a new item to the download queue
func AddToQueue(id, trackName, artistName, albumName, isrc string) { func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() defer downloadQueueLock.Unlock()
@@ -223,7 +204,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueue = append(downloadQueue, item) downloadQueue = append(downloadQueue, item)
// Initialize session start time if this is the first item
sessionStartLock.Lock() sessionStartLock.Lock()
if sessionStartTime == 0 { if sessionStartTime == 0 {
sessionStartTime = time.Now().Unix() sessionStartTime = time.Now().Unix()
@@ -231,7 +211,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
sessionStartLock.Unlock() sessionStartLock.Unlock()
} }
// StartDownloadItem marks an item as currently downloading
func StartDownloadItem(id string) { func StartDownloadItem(id string) {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() defer downloadQueueLock.Unlock()
@@ -250,7 +229,6 @@ func StartDownloadItem(id string) {
currentItemLock.Unlock() currentItemLock.Unlock()
} }
// UpdateItemProgress updates the progress of the current download item
func UpdateItemProgress(id string, progress, speed float64) { func UpdateItemProgress(id string, progress, speed float64) {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() 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 { func GetCurrentItemID() string {
currentItemLock.RLock() currentItemLock.RLock()
defer currentItemLock.RUnlock() defer currentItemLock.RUnlock()
return currentItemID return currentItemID
} }
// CompleteDownloadItem marks an item as completed
func CompleteDownloadItem(id, filePath string, finalSize float64) { func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() defer downloadQueueLock.Unlock()
@@ -284,7 +260,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueue[i].Progress = finalSize downloadQueue[i].Progress = finalSize
downloadQueue[i].TotalSize = finalSize downloadQueue[i].TotalSize = finalSize
// Add to total downloaded
totalDownloadedLock.Lock() totalDownloadedLock.Lock()
totalDownloaded += finalSize totalDownloaded += finalSize
totalDownloadedLock.Unlock() totalDownloadedLock.Unlock()
@@ -293,7 +268,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
} }
} }
// FailDownloadItem marks an item as failed
func FailDownloadItem(id, errorMsg string) { func FailDownloadItem(id, errorMsg string) {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() 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) { func SkipDownloadItem(id, filePath string) {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() defer downloadQueueLock.Unlock()
@@ -323,9 +296,8 @@ func SkipDownloadItem(id, filePath string) {
} }
} }
// GetDownloadQueue returns the complete download queue state
func GetDownloadQueue() DownloadQueueInfo { func GetDownloadQueue() DownloadQueueInfo {
// Auto-reset session if all downloads are complete
ResetSessionIfComplete() ResetSessionIfComplete()
downloadQueueLock.RLock() downloadQueueLock.RLock()
@@ -347,7 +319,6 @@ func GetDownloadQueue() DownloadQueueInfo {
sessionStart := sessionStartTime sessionStart := sessionStartTime
sessionStartLock.RUnlock() sessionStartLock.RUnlock()
// Count statuses
var queued, completed, failed, skipped int var queued, completed, failed, skipped int
for _, item := range downloadQueue { for _, item := range downloadQueue {
switch item.Status { switch item.Status {
@@ -362,7 +333,6 @@ func GetDownloadQueue() DownloadQueueInfo {
} }
} }
// Create a copy of the queue
queueCopy := make([]DownloadItem, len(downloadQueue)) queueCopy := make([]DownloadItem, len(downloadQueue))
copy(queueCopy, downloadQueue) copy(queueCopy, downloadQueue)
@@ -379,12 +349,10 @@ func GetDownloadQueue() DownloadQueueInfo {
} }
} }
// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
func ClearDownloadQueue() { func ClearDownloadQueue() {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() defer downloadQueueLock.Unlock()
// Keep only queued and downloading items
newQueue := make([]DownloadItem, 0) newQueue := make([]DownloadItem, 0)
for _, item := range downloadQueue { for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading { if item.Status == StatusQueued || item.Status == StatusDownloading {
@@ -394,7 +362,6 @@ func ClearDownloadQueue() {
downloadQueue = newQueue downloadQueue = newQueue
} }
// ClearAllDownloads clears the entire queue and resets session stats
func ClearAllDownloads() { func ClearAllDownloads() {
downloadQueueLock.Lock() downloadQueueLock.Lock()
downloadQueue = []DownloadItem{} downloadQueue = []DownloadItem{}
@@ -412,13 +379,10 @@ func ClearAllDownloads() {
currentItemID = "" currentItemID = ""
currentItemLock.Unlock() currentItemLock.Unlock()
// Reset current progress and speed
SetDownloadProgress(0) SetDownloadProgress(0)
SetDownloadSpeed(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() { func CancelAllQueuedItems() {
downloadQueueLock.Lock() downloadQueueLock.Lock()
defer downloadQueueLock.Unlock() 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() { func ResetSessionIfComplete() {
downloadQueueLock.RLock() downloadQueueLock.RLock()
hasActiveOrQueued := false hasActiveOrQueued := false
@@ -445,8 +407,6 @@ func ResetSessionIfComplete() {
} }
downloadQueueLock.RUnlock() downloadQueueLock.RUnlock()
// If no active or queued items, reset session stats
// But keep the queue items for history visibility
if !hasActiveOrQueued { if !hasActiveOrQueued {
sessionStartLock.Lock() sessionStartLock.Lock()
sessionStartTime = 0 sessionStartTime = 0
+44 -47
View File
@@ -78,7 +78,7 @@ func NewQobuzDownloader() *QobuzDownloader {
} }
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID) 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 var searchResp QobuzSearchResponse
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err) 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 { if err := json.Unmarshal(body, &searchResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body) bodyStr := string(body)
if len(bodyStr) > 200 { if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..." bodyStr = bodyStr[:200] + "..."
@@ -120,20 +120,17 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
} }
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, 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
qualityCode := quality // Use the provided quality parameter
if qualityCode == "" { 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("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, 27=Hi-Res\n")
// Decode base64 API URLs
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9") primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
// Try primary API first
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode) primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
fmt.Printf("Qobuz API URL: %s\n", primaryURL) fmt.Printf("Qobuz API URL: %s\n", primaryURL)
@@ -154,7 +151,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
resp.Body.Close() resp.Body.Close()
} }
// Fallback to secondary API
fmt.Println("Primary API failed, trying fallback...") fmt.Println("Primary API failed, trying fallback...")
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==") fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode) fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
@@ -184,7 +180,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
var streamResp QobuzStreamResponse var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil { if err := json.Unmarshal(body, &streamResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body) bodyStr := string(body)
if len(bodyStr) > 200 { if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..." bodyStr = bodyStr[:200] + "..."
@@ -202,10 +198,9 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
func (q *QobuzDownloader) DownloadFile(url, filepath string) error { func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...") 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{ downloadClient := &http.Client{
Timeout: 5 * time.Minute, // 5 minutes for large files Timeout: 5 * time.Minute,
} }
resp, err := downloadClient.Get(url) resp, err := downloadClient.Get(url)
@@ -226,14 +221,13 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
defer out.Close() defer out.Close()
fmt.Println("Downloading...") fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out) pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body) _, err = io.Copy(pw, resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("failed to write file: %w", err) 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.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil return nil
} }
@@ -263,42 +257,52 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
return err return err
} }
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string var filename string
// Determine track number to use
numberToUse := position numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 { if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber numberToUse = trackNumber
} }
// Check if format is a template (contains {}) year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
if strings.Contains(format, "{") { if strings.Contains(format, "{") {
filename = format filename = format
filename = strings.ReplaceAll(filename, "{title}", title) filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist) filename = strings.ReplaceAll(filename, "{artist}", artist)
filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
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 { if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse)) filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else { } else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
} }
} else { } else {
// Legacy format support
switch format { switch format {
case "artist-title": case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title) filename = fmt.Sprintf("%s - %s", artist, title)
case "title": case "title":
filename = title filename = title
default: // "title-artist" default:
filename = fmt.Sprintf("%s - %s", title, artist) filename = fmt.Sprintf("%s - %s", title, artist)
} }
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 { if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename) filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
} }
@@ -307,22 +311,20 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in
return filename + ".flac" 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) { 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", isrc) fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
// Create output directory if it doesn't exist
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err) return "", fmt.Errorf("failed to create output directory: %w", err)
} }
} }
track, err := q.SearchByISRC(isrc) track, err := q.SearchByISRC(deezerISRC)
if err != nil { if err != nil {
return "", err return "", err
} }
// All metadata from Spotify - no fallback to Qobuz
artists := spotifyArtistName artists := spotifyArtistName
trackTitle := spotifyTrackName trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName albumTitle := spotifyAlbumName
@@ -346,7 +348,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
return "", fmt.Errorf("received empty download URL") return "", fmt.Errorf("received empty download URL")
} }
// Show partial URL for security
urlPreview := downloadURL urlPreview := downloadURL
if len(downloadURL) > 60 { if len(downloadURL) > 60 {
urlPreview = downloadURL[:60] + "..." urlPreview = downloadURL[:60] + "..."
@@ -355,15 +356,10 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists) safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle) safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists (use Spotify ISRC) filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
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, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename) filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
@@ -379,7 +375,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Printf("Downloaded: %s\n", filepath) fmt.Printf("Downloaded: %s\n", filepath)
coverPath := "" coverPath := ""
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
if spotifyCoverURL != "" { if spotifyCoverURL != "" {
coverPath = filepath + ".cover.jpg" coverPath = filepath + ".cover.jpg"
coverClient := NewCoverClient() coverClient := NewCoverClient()
@@ -394,23 +390,24 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Println("Embedding metadata and cover art...") fmt.Println("Embedding metadata and cover art...")
// Determine track number to embed - ALL from Spotify
trackNumberToEmbed := spotifyTrackNumber trackNumberToEmbed := spotifyTrackNumber
if position > 0 && !useAlbumTrackNumber { if trackNumberToEmbed == 0 {
trackNumberToEmbed = position // Use playlist position trackNumberToEmbed = 1
} }
// ALL metadata from Spotify
metadata := Metadata{ metadata := Metadata{
Title: trackTitle, Title: trackTitle,
Artist: artists, Artist: artists,
Album: albumTitle, Album: albumTitle,
AlbumArtist: spotifyAlbumArtist, AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD) Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed, TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber, // Disc number from Spotify DiscNumber: spotifyDiscNumber,
ISRC: isrc, // ISRC from Spotify (passed as parameter) TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC", Description: "https://github.com/afkarxyz/SpotiFLAC",
} }
+21 -35
View File
@@ -5,7 +5,6 @@ import (
"unicode" "unicode"
) )
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{ var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
@@ -17,20 +16,19 @@ var hiraganaToRomaji = map[rune]string{
'や': "ya", 'ゆ': "yu", 'よ': "yo", 'や': "ya", 'ゆ': "yu", 'よ': "yo",
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro", 'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
'わ': "wa", 'を': "wo", 'ん': "n", 'わ': "wa", 'を': "wo", 'ん': "n",
// Dakuten (voiced)
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go", 'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo", 'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do", 'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo", 'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
// Handakuten (semi-voiced)
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po", 'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
// Small characters
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo", 'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'っ': "", // Double consonant marker 'っ': "",
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
} }
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{ var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
@@ -42,23 +40,22 @@ var katakanaToRomaji = map[rune]string{
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo", 'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro", 'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
'ワ': "wa", 'ヲ': "wo", 'ン': "n", 'ワ': "wa", 'ヲ': "wo", 'ン': "n",
// Dakuten (voiced)
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go", 'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo", 'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do", 'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo", 'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
// Handakuten (semi-voiced)
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po", 'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
// Small characters
'ャ': "ya", 'ュ': "yu", 'ョ': "yo", 'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ッ': "", // Double consonant marker 'ッ': "",
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o", 'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
// Extended katakana
'ー': "", // Long vowel mark 'ー': "",
'ヴ': "vu", 'ヴ': "vu",
} }
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{ var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho", "しゃ": "sha", "しゅ": "shu", "しょ": "sho",
@@ -85,13 +82,12 @@ var combinationKatakana = map[string]string{
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo", "ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo", "ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo", "ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
// Extended combinations
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du", "ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo", "ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo", "ウィ": "wi", "ウェ": "we", "ウォ": "wo",
} }
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool { func ContainsJapanese(s string) bool {
for _, r := range s { for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) { if isHiragana(r) || isKatakana(r) || isKanji(r) {
@@ -110,12 +106,10 @@ func isKatakana(r rune) bool {
} }
func isKanji(r rune) bool { func isKanji(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs return (r >= 0x4E00 && r <= 0x9FFF) ||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A (r >= 0x3400 && r <= 0x4DBF)
} }
// 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 { func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) { if !ContainsJapanese(text) {
return text return text
@@ -126,7 +120,7 @@ func JapaneseToRomaji(text string) string {
i := 0 i := 0
for i < len(runes) { for i < len(runes) {
// Check for っ/ッ (double consonant)
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') { if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
nextRomaji := "" nextRomaji := ""
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok { if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
@@ -135,13 +129,12 @@ func JapaneseToRomaji(text string) string {
nextRomaji = romaji nextRomaji = romaji
} }
if len(nextRomaji) > 0 { if len(nextRomaji) > 0 {
result.WriteByte(nextRomaji[0]) // Double the first consonant result.WriteByte(nextRomaji[0])
} }
i++ i++
continue continue
} }
// Check for two-character combinations
if i < len(runes)-1 { if i < len(runes)-1 {
combo := string(runes[i : i+2]) combo := string(runes[i : i+2])
if romaji, ok := combinationHiragana[combo]; ok { if romaji, ok := combinationHiragana[combo]; ok {
@@ -156,17 +149,16 @@ func JapaneseToRomaji(text string) string {
} }
} }
// Single character conversion
r := runes[i] r := runes[i]
if romaji, ok := hiraganaToRomaji[r]; ok { if romaji, ok := hiraganaToRomaji[r]; ok {
result.WriteString(romaji) result.WriteString(romaji)
} else if romaji, ok := katakanaToRomaji[r]; ok { } else if romaji, ok := katakanaToRomaji[r]; ok {
result.WriteString(romaji) result.WriteString(romaji)
} else if isKanji(r) { } else if isKanji(r) {
// Keep kanji as-is (would need dictionary for proper conversion)
result.WriteRune(r) result.WriteRune(r)
} else { } else {
// Keep other characters (punctuation, spaces, etc.)
result.WriteRune(r) result.WriteRune(r)
} }
i++ i++
@@ -175,21 +167,17 @@ func JapaneseToRomaji(text string) string {
return result.String() 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 { func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName) trackRomaji := JapaneseToRomaji(trackName)
artistRomaji := JapaneseToRomaji(artistName) artistRomaji := JapaneseToRomaji(artistName)
// Clean up the query - remove special characters that might interfere with search
trackClean := cleanSearchQuery(trackRomaji) trackClean := cleanSearchQuery(trackRomaji)
artistClean := cleanSearchQuery(artistRomaji) artistClean := cleanSearchQuery(artistRomaji)
return strings.TrimSpace(artistClean + " " + trackClean) return strings.TrimSpace(artistClean + " " + trackClean)
} }
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string { func cleanSearchQuery(s string) string {
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
@@ -202,21 +190,19 @@ func cleanSearchQuery(s string) string {
return strings.TrimSpace(result.String()) 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 { func cleanToASCII(s string) string {
var result strings.Builder var result strings.Builder
for _, r := range s { for _, r := range s {
// Keep only ASCII letters, numbers, spaces, and basic punctuation
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' { (r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
result.WriteRune(r) result.WriteRune(r)
} else if r == ',' || r == '.' { } else if r == ',' || r == '.' {
// Convert punctuation to space
result.WriteRune(' ') result.WriteRune(' ')
} }
} }
// Clean up multiple spaces
cleaned := strings.Join(strings.Fields(result.String()), " ") cleaned := strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(cleaned) return strings.TrimSpace(cleaned)
} }
+152 -30
View File
@@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
) )
@@ -22,7 +23,6 @@ type SongLinkURLs struct {
AmazonURL string `json:"amazon_url"` AmazonURL string `json:"amazon_url"`
} }
// TrackAvailability represents the availability of a track on different platforms
type TrackAvailability struct { type TrackAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
@@ -43,14 +43,13 @@ func NewSongLinkClient() *SongLinkClient {
} }
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) { func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now() now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute { if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0 s.apiCallCount = 0
s.apiCallResetTime = now s.apiCallResetTime = now
} }
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 { if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime) waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 { 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() { if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime) timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second minDelay := 7 * time.Second
@@ -72,7 +70,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
} }
} }
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) 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...") fmt.Println("Getting streaming URLs from song.link...")
// Retry logic for rate limit errors
maxRetries := 3 maxRetries := 3
var resp *http.Response var resp *http.Response
for i := 0; i < maxRetries; i++ { 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) return nil, fmt.Errorf("failed to get URLs: %w", err)
} }
// Update rate limit tracking
s.lastAPICallTime = time.Now() s.lastAPICallTime = time.Now()
s.apiCallCount++ s.apiCallCount++
@@ -124,7 +119,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
URL string `json:"url"` URL string `json:"url"`
} `json:"linksByPlatform"` } `json:"linksByPlatform"`
} }
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err) 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 { if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body) bodyStr := string(body)
if len(bodyStr) > 200 { if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..." bodyStr = bodyStr[:200] + "..."
@@ -145,23 +140,20 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
urls := &SongLinkURLs{} urls := &SongLinkURLs{}
// Extract Tidal URL
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
urls.TidalURL = tidalLink.URL urls.TidalURL = tidalLink.URL
fmt.Printf("✓ Tidal URL found\n") fmt.Printf("✓ Tidal URL found\n")
} }
// Extract Amazon URL
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if len(amazonURL) > 0 { if len(amazonURL) > 0 {
urls.AmazonURL = amazonURL urls.AmazonURL = amazonURL
fmt.Printf("✓ Amazon URL found\n") fmt.Printf("✓ Amazon URL found\n")
} }
} }
// Check if at least one URL was found
if urls.TidalURL == "" && urls.AmazonURL == "" { if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found") return nil, fmt.Errorf("no streaming URLs found")
} }
@@ -169,16 +161,14 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return urls, nil return urls, nil
} }
// CheckTrackAvailability checks the availability of a track on different platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now() now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute { if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0 s.apiCallCount = 0
s.apiCallResetTime = now s.apiCallResetTime = now
} }
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 { if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime) waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 { 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() { if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime) timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second 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==") spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) 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) fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
// Retry logic for rate limit errors
maxRetries := 3 maxRetries := 3
var resp *http.Response var resp *http.Response
for i := 0; i < maxRetries; i++ { 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) return nil, fmt.Errorf("failed to check availability: %w", err)
} }
// Update rate limit tracking
s.lastAPICallTime = time.Now() s.lastAPICallTime = time.Now()
s.apiCallCount++ s.apiCallCount++
@@ -252,7 +238,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
URL string `json:"url"` URL string `json:"url"`
} `json:"linksByPlatform"` } `json:"linksByPlatform"`
} }
// Read body first to handle encoding issues
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err) 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 { if err := json.Unmarshal(body, &songLinkResp); err != nil {
// Truncate body for error message (max 200 chars)
bodyStr := string(body) bodyStr := string(body)
if len(bodyStr) > 200 { if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..." bodyStr = bodyStr[:200] + "..."
@@ -275,33 +261,33 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
SpotifyID: spotifyTrackID, SpotifyID: spotifyTrackID,
} }
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true availability.Tidal = true
availability.TidalURL = tidalLink.URL availability.TidalURL = tidalLink.URL
} }
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true availability.Amazon = true
availability.AmazonURL = amazonLink.URL availability.AmazonURL = amazonLink.URL
} }
// Check Qobuz using ISRC (song.link doesn't support Qobuz) if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
if isrc != "" { deezerURL := deezerLink.URL
qobuzAvailable := checkQobuzAvailability(isrc)
availability.Qobuz = qobuzAvailable deezerISRC, err := GetDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" {
qobuzAvailable := checkQobuzAvailability(deezerISRC)
availability.Qobuz = qobuzAvailable
}
} }
return availability, nil return availability, nil
} }
// checkQobuzAvailability checks if a track is available on Qobuz using ISRC
func checkQobuzAvailability(isrc string) bool { func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057" appID := "798273057"
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID) 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 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" "github.com/mewkiz/flac"
) )
// SpectrumData contains frequency spectrum information
type SpectrumData struct { type SpectrumData struct {
TimeSlices []TimeSlice `json:"time_slices"` TimeSlices []TimeSlice `json:"time_slices"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
@@ -17,15 +16,13 @@ type SpectrumData struct {
MaxFreq float64 `json:"max_freq"` MaxFreq float64 `json:"max_freq"`
} }
// TimeSlice represents spectrum data at a point in time
type TimeSlice struct { type TimeSlice struct {
Time float64 `json:"time"` Time float64 `json:"time"`
Magnitudes []float64 `json:"magnitudes"` Magnitudes []float64 `json:"magnitudes"`
} }
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
// Open FLAC file
stream, err := flac.ParseFile(filepath) stream, err := flac.ParseFile(filepath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse FLAC: %w", err) return nil, fmt.Errorf("failed to parse FLAC: %w", err)
@@ -36,7 +33,6 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
sampleRate := int(info.SampleRate) sampleRate := int(info.SampleRate)
channels := int(info.NChannels) channels := int(info.NChannels)
// Read audio samples
samples, err := readSamples(stream, channels) samples, err := readSamples(stream, channels)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read samples: %w", err) 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") return nil, fmt.Errorf("no audio samples found")
} }
// Calculate spectrum
return calculateSpectrum(samples, sampleRate), nil return calculateSpectrum(samples, sampleRate), nil
} }
// readSamples reads and decodes audio samples from FLAC stream
func readSamples(stream *flac.Stream, channels int) ([]float64, error) { func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
var allSamples []float64 var allSamples []float64
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues maxSamples := 10 * 1024 * 1024
// Decode frames
for { for {
frame, err := stream.ParseNext() frame, err := stream.ParseNext()
if err != nil { if err != nil {
// End of stream
break break
} }
// Convert samples to float64 and mix channels to mono
for i := 0; i < frame.Subframes[0].NSamples; i++ { for i := 0; i < frame.Subframes[0].NSamples; i++ {
var sample float64 var sample float64
// Mix all channels to mono by averaging
for ch := 0; ch < channels; ch++ { for ch := 0; ch < channels; ch++ {
sample += float64(frame.Subframes[ch].Samples[i]) sample += float64(frame.Subframes[ch].Samples[i])
} }
@@ -75,7 +66,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
allSamples = append(allSamples, sample) allSamples = append(allSamples, sample)
// Limit sample count
if len(allSamples) >= maxSamples { if len(allSamples) >= maxSamples {
return allSamples, nil return allSamples, nil
} }
@@ -85,7 +75,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
return allSamples, nil return allSamples, nil
} }
// calculateSpectrum performs FFT analysis on audio samples
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData { func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
fftSize := 8192 fftSize := 8192
numTimeSlices := 300 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 { func applyHannWindow(samples []float64) []float64 {
n := len(samples) n := len(samples)
windowed := make([]float64, n) windowed := make([]float64, n)
@@ -153,7 +141,6 @@ func applyHannWindow(samples []float64) []float64 {
return windowed return windowed
} }
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
func fft(samples []float64) []complex128 { func fft(samples []float64) []complex128 {
n := len(samples) n := len(samples)
@@ -165,7 +152,6 @@ func fft(samples []float64) []complex128 {
return fftRecursive(x) return fftRecursive(x)
} }
// fftRecursive performs recursive FFT
func fftRecursive(x []complex128) []complex128 { func fftRecursive(x []complex128) []complex128 {
n := len(x) n := len(x)
+1686
View File
File diff suppressed because it is too large Load Diff
+1000 -839
View File
File diff suppressed because it is too large Load Diff
+96 -479
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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+Flex:opsz,wght@6..144,1..1000&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=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> <title>SpotiFLAC</title>
</head> </head>
<body> <body>
+6 -6
View File
@@ -27,7 +27,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.12.1", "motion": "^12.25.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -37,18 +37,18 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@types/node": "^25.0.3", "@types/node": "^25.0.6",
"@types/react": "^19.2.7", "@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0", "globals": "^17.0.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.50.0", "typescript-eslint": "^8.52.0",
"vite": "^7.3.0" "vite": "^7.3.1"
} }
} }
+1 -1
View File
@@ -1 +1 @@
c94dda3302d3338d7909ef5d634d0fde 6f2a6dc27f7d8d215283f6d07b4eaa54
+518 -491
View File
File diff suppressed because it is too large Load Diff
+372 -571
View File
File diff suppressed because it is too large Load Diff
+87 -219
View File
@@ -7,144 +7,85 @@ import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps { interface AlbumInfoProps {
albumInfo: { albumInfo: {
name: string; name: string;
artists: string; artists: string;
images: string; images: string;
release_date: string; release_date: string;
total_tracks: number; total_tracks: number;
artist_id?: string; artist_id?: string;
artist_url?: string; artist_url?: string;
}; };
trackList: TrackMetadata[]; trackList: TrackMetadata[];
searchQuery: string; searchQuery: string;
sortBy: string; sortBy: string;
selectedTracks: string[]; selectedTracks: string[];
downloadedTracks: Set<string>; downloadedTracks: Set<string>;
failedTracks: Set<string>; failedTracks: Set<string>;
skippedTracks: Set<string>; skippedTracks: Set<string>;
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloading: boolean; isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null; bulkDownloadType: "all" | "selected" | null;
downloadProgress: number; downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null; currentDownloadInfo: {
currentPage: number; name: string;
itemsPerPage: number; artists: string;
// Lyrics props } | null;
downloadedLyrics?: Set<string>; currentPage: number;
failedLyrics?: Set<string>; itemsPerPage: number;
skippedLyrics?: Set<string>; downloadedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null; failedLyrics?: Set<string>;
// Availability props skippedLyrics?: Set<string>;
checkingAvailabilityTrack?: string | null; downloadingLyricsTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>; checkingAvailabilityTrack?: string | null;
// Cover props availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>; downloadedCovers?: Set<string>;
failedCovers?: Set<string>; failedCovers?: Set<string>;
skippedCovers?: Set<string>; skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null; downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean; isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean; isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => 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) => 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) => 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; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void; onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void; onDownloadAllCovers?: () => void;
onDownloadAll: () => void; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
onOpenFolder: () => void; onOpenFolder: () => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; onArtistClick?: (artist: {
onTrackClick?: (track: TrackMetadata) => void; 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) {
export function AlbumInfo({ return (<div className="space-y-6">
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> <Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{albumInfo.images && ( {albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
<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="flex-1 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium">Album</p> <p className="text-sm font-medium">Album</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2> <h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? ( {onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onArtistClick({
<span id: albumInfo.artist_id!,
className="font-medium cursor-pointer hover:underline" name: albumInfo.artists,
onClick={() => external_urls: albumInfo.artist_url!,
onArtistClick({ })}>
id: albumInfo.artist_id!,
name: albumInfo.artists,
external_urls: albumInfo.artist_url!,
})
}
>
{albumInfo.artists} {albumInfo.artists}
</span> </span>) : (<span className="font-medium">{albumInfo.artists}</span>)}
) : (
<span className="font-medium">{albumInfo.artists}</span>
)}
<span></span> <span></span>
<span>{albumInfo.release_date}</span> <span>{albumInfo.release_date}</span>
<span></span> <span></span>
@@ -155,119 +96,46 @@ export function AlbumInfo({
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}> <Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? ( {isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download All Download All
</Button> </Button>
{selectedTracks.length > 0 && ( {selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
<Button {isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
onClick={onDownloadSelected}
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </Button>)}
)} {onDownloadAllLyrics && (<Tooltip>
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
onClick={onDownloadAllLyrics} {isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download All Lyrics</p> <p>Download All Lyrics</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {onDownloadAllCovers && (<Tooltip>
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
onClick={onDownloadAllCovers} {isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download All Covers</p> <p>Download All Covers</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
{downloadedTracks.size > 0 && ( <FolderOpen className="h-4 w-4"/>
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder Open Folder
</Button> </Button>)}
)}
</div> </div>
{isDownloading && ( {isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="space-y-4"> <div className="space-y-4">
<SearchAndSort <SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
searchQuery={searchQuery} <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}/>
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}
/>
</div> </div>
</div> </div>);
);
} }
+403 -267
View File
@@ -1,315 +1,451 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react"; import { Download, FolderOpen, ImageDown, FileText, BadgeCheck } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; 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 { interface ArtistInfoProps {
artistInfo: { artistInfo: {
name: string; name: string;
images: string; images: string;
followers: number; header?: string;
genres: string[]; gallery?: string[];
}; followers: number;
albumList: Array<{ genres: string[];
id: string; biography?: string;
name: string; verified?: boolean;
images: string; listeners?: number;
release_date: string; rank?: number;
album_type: string; };
external_urls: string; albumList: Array<{
}>; id: string;
trackList: TrackMetadata[]; name: string;
searchQuery: string; images: string;
sortBy: string; release_date: string;
selectedTracks: string[]; album_type: string;
downloadedTracks: Set<string>; external_urls: string;
failedTracks: Set<string>; }>;
skippedTracks: Set<string>; trackList: TrackMetadata[];
downloadingTrack: string | null; searchQuery: string;
isDownloading: boolean; sortBy: string;
bulkDownloadType: "all" | "selected" | null; selectedTracks: string[];
downloadProgress: number; downloadedTracks: Set<string>;
currentDownloadInfo: { name: string; artists: string } | null; failedTracks: Set<string>;
currentPage: number; skippedTracks: Set<string>;
itemsPerPage: number; downloadingTrack: string | null;
// Lyrics props isDownloading: boolean;
downloadedLyrics?: Set<string>; bulkDownloadType: "all" | "selected" | null;
failedLyrics?: Set<string>; downloadProgress: number;
skippedLyrics?: Set<string>; currentDownloadInfo: {
downloadingLyricsTrack?: string | null; name: string;
// Availability props artists: string;
checkingAvailabilityTrack?: string | null; } | null;
availabilityMap?: Map<string, TrackAvailability>; currentPage: number;
// Cover props itemsPerPage: number;
downloadedCovers?: Set<string>; downloadedLyrics?: Set<string>;
failedCovers?: Set<string>; failedLyrics?: Set<string>;
skippedCovers?: Set<string>; skippedLyrics?: Set<string>;
downloadingCoverTrack?: string | null; downloadingLyricsTrack?: string | null;
isBulkDownloadingCovers?: boolean; checkingAvailabilityTrack?: string | null;
isBulkDownloadingLyrics?: boolean; availabilityMap?: Map<string, TrackAvailability>;
onSearchChange: (value: string) => void; downloadedCovers?: Set<string>;
onSortChange: (value: string) => void; failedCovers?: Set<string>;
onToggleTrack: (isrc: string) => void; skippedCovers?: Set<string>;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; downloadingCoverTrack?: string | null;
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; isBulkDownloadingCovers?: boolean;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; isBulkDownloadingLyrics?: boolean;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onSearchChange: (value: string) => void;
onCheckAvailability?: (spotifyId: string) => void; onSortChange: (value: string) => void;
onDownloadAllLyrics?: () => void; onToggleTrack: (isrc: string) => void;
onDownloadAllCovers?: () => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadAll: () => 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;
onDownloadSelected: () => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onStopDownload: () => 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;
onOpenFolder: () => void; onCheckAvailability?: (spotifyId: string) => void;
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void; onDownloadAllLyrics?: () => void;
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void; onDownloadAllCovers?: () => void;
onPageChange: (page: number) => void; onDownloadAll: () => void;
onTrackClick?: (track: TrackMetadata) => void; 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;
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) {
export function ArtistInfo({ const [downloadingHeader, setDownloadingHeader] = useState(false);
artistInfo, const [downloadingAvatar, setDownloadingAvatar] = useState(false);
albumList, const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
trackList, const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
searchQuery, const handleDownloadHeader = async () => {
sortBy, if (!artistInfo.header)
selectedTracks, return;
downloadedTracks, setDownloadingHeader(true);
failedTracks, try {
skippedTracks, const settings = getSettings();
downloadingTrack, const response = await downloadHeader({
isDownloading, header_url: artistInfo.header,
bulkDownloadType, artist_name: artistInfo.name,
downloadProgress, output_dir: settings.downloadPath,
currentDownloadInfo, });
currentPage, if (response.success) {
itemsPerPage, if (response.already_exists) {
downloadedLyrics, toast.info("Header already exists");
failedLyrics, }
skippedLyrics, else {
downloadingLyricsTrack, toast.success("Header downloaded successfully");
checkingAvailabilityTrack, }
availabilityMap, }
downloadedCovers, else {
failedCovers, toast.error(response.error || "Failed to download header");
skippedCovers, }
downloadingCoverTrack, }
isBulkDownloadingCovers, catch (error) {
isBulkDownloadingLyrics, toast.error(`Error downloading header: ${error}`);
onSearchChange, }
onSortChange, finally {
onToggleTrack, setDownloadingHeader(false);
onToggleSelectAll, }
onDownloadTrack, };
onDownloadLyrics, const handleDownloadAvatar = async () => {
onDownloadCover, if (!artistInfo.images)
onCheckAvailability, return;
onDownloadAllLyrics, setDownloadingAvatar(true);
onDownloadAllCovers, try {
onDownloadAll, const settings = getSettings();
onDownloadSelected, const response = await downloadAvatar({
onStopDownload, avatar_url: artistInfo.images,
onOpenFolder, artist_name: artistInfo.name,
onAlbumClick, output_dir: settings.downloadPath,
onArtistClick, });
onPageChange, if (response.success) {
onTrackClick, if (response.already_exists) {
}: ArtistInfoProps) { toast.info("Avatar already exists");
return ( }
<div className="space-y-6"> else {
<Card> toast.success("Avatar downloaded successfully");
<CardContent className="px-6"> }
<div className="flex gap-6 items-start"> }
{artistInfo.images && ( else {
<img toast.error(response.error || "Failed to download avatar");
src={artistInfo.images} }
alt={artistInfo.name} }
className="w-48 h-48 rounded-full shadow-lg object-cover" catch (error) {
/> toast.error(`Error downloading avatar: ${error}`);
)} }
<div className="flex-1 space-y-2"> finally {
<p className="text-sm font-medium">Artist</p> setDownloadingAvatar(false);
<h2 className="text-4xl font-bold">{artistInfo.name}</h2> }
<div className="flex items-center gap-2 text-sm flex-wrap"> };
<span>{artistInfo.followers.toLocaleString()} followers</span> const handleDownloadGalleryImage = async (imageUrl: string, index: number) => {
<span></span> setDownloadingGalleryIndex(index);
<span>{albumList.length} albums</span> try {
<span></span> const settings = getSettings();
<span>{trackList.length} tracks</span> const response = await downloadGalleryImage({
{artistInfo.genres.length > 0 && ( image_url: imageUrl,
<> artist_name: artistInfo.name,
<span></span> image_index: index,
<span>{artistInfo.genres.join(", ")}</span> 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 && (<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>
</div> </div>
</div> </>) : (<CardContent className="px-6 py-6">
</CardContent> <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 && (<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>)}
</div>
</div>
</div>
</CardContent>)}
</Card> </Card>
{albumList.length > 0 && ( {artistInfo.gallery && artistInfo.gallery.length > 0 && (<div className="space-y-4">
<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> <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"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{albumList.map((album) => ( {albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
<div
key={album.id}
className="group cursor-pointer"
onClick={() =>
onAlbumClick({
id: album.id, id: album.id,
name: album.name, name: album.name,
external_urls: album.external_urls, external_urls: album.external_urls,
}) })}>
}
>
<div className="relative mb-4"> <div className="relative mb-4">
{album.images && ( {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"/>)}
<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> </div>
<h4 className="font-semibold truncate">{album.name}</h4> <h4 className="font-semibold truncate">{album.name}</h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{album.release_date?.split("-")[0]} {album.album_type} {album.release_date?.split("-")[0]}
</p> </p>
</div> </div>))}
))}
</div> </div>
</div> </div>)}
)}
{trackList.length > 0 && ( {trackList.length > 0 && (<div className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2"> <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"> <div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}> <Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? ( {isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download All Download All
</Button> </Button>
{selectedTracks.length > 0 && ( {selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
<Button {isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
onClick={onDownloadSelected}
size="sm"
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </Button>)}
)} {onDownloadAllLyrics && (<Tooltip>
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={onDownloadAllLyrics} size="sm" variant="outline" disabled={isBulkDownloadingLyrics}>
onClick={onDownloadAllLyrics} {isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
size="sm"
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download All Lyrics</p> <p>Download All Lyrics</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {onDownloadAllCovers && (<Tooltip>
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={onDownloadAllCovers} size="sm" variant="outline" disabled={isBulkDownloadingCovers}>
onClick={onDownloadAllCovers} {isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
size="sm"
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download All Covers</p> <p>Download All Covers</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
{downloadedTracks.size > 0 && ( <FolderOpen className="h-4 w-4"/>
<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder Open Folder
</Button> </Button>)}
)}
</div> </div>
</div> </div>
{isDownloading && ( {isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<DownloadProgress <SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
progress={downloadProgress} <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}/>
currentTrack={currentDownloadInfo} </div>)}
onStop={onStopDownload} </div>);
/>
)}
<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>
);
} }
+53 -89
View File
@@ -1,144 +1,109 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
Activity,
Waves,
Radio,
TrendingUp,
FileAudio,
Clock,
Gauge,
HardDrive
} from "lucide-react";
import type { AnalysisResult } from "@/types/api"; import type { AnalysisResult } from "@/types/api";
interface AudioAnalysisProps { interface AudioAnalysisProps {
result: AnalysisResult | null; result: AnalysisResult | null;
analyzing: boolean; analyzing: boolean;
onAnalyze?: () => void; onAnalyze?: () => void;
showAnalyzeButton?: boolean; showAnalyzeButton?: boolean;
filePath?: string; filePath?: string;
} }
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
export function AudioAnalysis({ if (analyzing) {
result, return (<Card>
analyzing,
onAnalyze,
showAnalyzeButton = true,
filePath
}: AudioAnalysisProps) {
if (analyzing) {
return (
<Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex items-center justify-center py-8 gap-3"> <div className="flex items-center justify-center py-8 gap-3">
<Spinner /> <Spinner />
<span className="text-muted-foreground">Analyzing audio quality...</span> <span className="text-muted-foreground">Analyzing audio quality...</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>);
); }
} if (!result && showAnalyzeButton) {
return (<Card>
if (!result && showAnalyzeButton) {
return (
<Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex flex-col items-center justify-center py-8 gap-4"> <div className="flex flex-col items-center justify-center py-8 gap-4">
<Activity className="h-12 w-12 text-primary" /> <Activity className="h-12 w-12 text-primary"/>
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<p className="font-medium">Audio Quality Analysis</p> <p className="font-medium">Audio Quality Analysis</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Verify the true lossless quality of downloaded files Verify the true lossless quality of downloaded files
</p> </p>
</div> </div>
{onAnalyze && ( {onAnalyze && (<Button onClick={onAnalyze}>
<Button onClick={onAnalyze}> <Activity className="h-4 w-4"/>
<Activity className="h-4 w-4" />
Analyze Audio Analyze Audio
</Button> </Button>)}
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>);
); }
} if (!result) {
return null;
if (!result) { }
return null; const formatDuration = (seconds: number) => {
} const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const formatDuration = (seconds: number) => { return `${mins}:${secs.toString().padStart(2, '0')}`;
const mins = Math.floor(seconds / 60); };
const secs = Math.floor(seconds % 60); const formatNumber = (num: number) => {
return `${mins}:${secs.toString().padStart(2, '0')}`; return num.toFixed(2);
}; };
const formatFileSize = (bytes: number): string => {
const formatNumber = (num: number) => { if (bytes === 0)
return num.toFixed(2); return "0 B";
}; const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const formatFileSize = (bytes: number): string => { const i = Math.floor(Math.log(bytes) / Math.log(k));
if (bytes === 0) return "0 B"; return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
const k = 1024; };
const sizes = ["B", "KB", "MB", "GB"]; const nyquistFreq = result.sample_rate / 2;
const i = Math.floor(Math.log(bytes) / Math.log(k)); return (<Card className="gap-2">
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">
<CardHeader> <CardHeader>
{filePath && ( {filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
<p className="text-sm font-mono break-all">{filePath}</p>
)}
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <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 flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground" /> <Radio className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Sample Rate:</span> <span className="text-muted-foreground">Sample Rate:</span>
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span> <span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FileAudio className="h-3 w-3 text-muted-foreground" /> <FileAudio className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Bit Depth:</span> <span className="text-muted-foreground">Bit Depth:</span>
<span className="font-semibold">{result.bit_depth}</span> <span className="font-semibold">{result.bit_depth}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Waves className="h-3 w-3 text-muted-foreground" /> <Waves className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Channels:</span> <span className="text-muted-foreground">Channels:</span>
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span> <span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground" /> <Clock className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span> <span className="text-muted-foreground">Duration:</span>
<span className="font-semibold">{formatDuration(result.duration)}</span> <span className="font-semibold">{formatDuration(result.duration)}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground" /> <Gauge className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Nyquist:</span> <span className="text-muted-foreground">Nyquist:</span>
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span> <span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
</div> </div>
{result.file_size > 0 && ( {result.file_size > 0 && (<div className="flex items-center gap-1">
<div className="flex items-center gap-1"> <HardDrive className="h-3 w-3 text-muted-foreground"/>
<HardDrive className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">Size:</span> <span className="text-muted-foreground">Size:</span>
<span className="font-semibold">{formatFileSize(result.file_size)}</span> <span className="font-semibold">{formatFileSize(result.file_size)}</span>
</div> </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 flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<TrendingUp className="h-3 w-3 text-muted-foreground" /> <TrendingUp className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Dynamic Range:</span> <span className="text-muted-foreground">Dynamic Range:</span>
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span> <span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
</div> </div>
@@ -156,6 +121,5 @@ export function AudioAnalysis({
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>);
);
} }
+77 -119
View File
@@ -7,149 +7,107 @@ import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App"; import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps { interface AudioAnalysisPageProps {
onBack?: () => void; onBack?: () => void;
} }
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis(); const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
const handleSelectFile = async () => { try {
try { const filePath = await SelectFile();
const filePath = await SelectFile(); if (filePath) {
if (filePath) { await analyzeFile(filePath);
await analyzeFile(filePath); }
} }
} catch (err) { catch (err) {
toast.error("File Selection Failed", { toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file", description: err instanceof Error ? err.message : "Failed to select file",
}); });
} }
};
const handleFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
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]
);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
}; };
}, [handleFileDrop]); const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
const handleAnalyzeAnother = () => { if (paths.length === 0)
clearResult(); return;
}; const filePath = paths[0];
if (!filePath.toLowerCase().endsWith(".flac")) {
return ( toast.error("Invalid File Type", {
<div className="space-y-6"> description: "Please drop a FLAC file for analysis",
{/* Header */} });
return;
}
await analyzeFile(filePath);
}, [analyzeFile]);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const handleAnalyzeAnother = () => {
clearResult();
};
return (<div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{onBack && ( {onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
<Button variant="ghost" size="icon" onClick={onBack}> <ArrowLeft className="h-5 w-5"/>
<ArrowLeft className="h-5 w-5" /> </Button>)}
</Button>
)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1> <h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div> </div>
{result && ( {result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm"> <Trash2 className="h-4 w-4"/>
<Trash2 className="h-4 w-4" />
Clear Clear
</Button> </Button>)}
)}
</div> </div>
{/* File Selection */}
{!result && !analyzing && ( {!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
<div ? "border-primary bg-primary/10"
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${ : "border-muted-foreground/30"}`} onDragOver={(e) => {
isDragging e.preventDefault();
? "border-primary bg-primary/10" setIsDragging(true);
: "border-muted-foreground/30" }} onDragLeave={(e) => {
}`} e.preventDefault();
onDragOver={(e) => { setIsDragging(false);
e.preventDefault(); }} onDrop={(e) => {
setIsDragging(true); e.preventDefault();
}} setIsDragging(false);
onDragLeave={(e) => { }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
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"> <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" /> <Upload className="h-8 w-8 text-primary"/>
</div> </div>
<p className="text-sm text-muted-foreground mb-4 text-center"> <p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging {isDragging
? "Drop your FLAC file here" ? "Drop your FLAC file here"
: "Drag and drop a FLAC file here, or click the button below to select"} : "Drag and drop a FLAC file here, or click the button below to select"}
</p> </p>
<Button onClick={handleSelectFile} size="lg"> <Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" /> <Upload className="h-5 w-5"/>
Select FLAC File Select FLAC File
</Button> </Button>
</div> </div>)}
)}
{/* Loading State */}
{analyzing && !result && ( {analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
<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> <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> <p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div> </div>)}
)}
{/* Analysis Results */}
{result && ( {result && (<div className="space-y-4">
<div className="space-y-4">
{/* Detailed Analysis */} <AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath} />
{/* Spectrum Visualization */}
{spectrumLoading ? ( {spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
<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> <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> <p className="text-sm text-muted-foreground">Loading spectrum data...</p>
</div> </div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
) : ( </div>)}
<SpectrumVisualization </div>);
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
)}
</div>
)}
</div>
);
} }
File diff suppressed because it is too large Load Diff
+53 -84
View File
@@ -2,100 +2,72 @@ import { useState, useEffect, useRef } from "react";
import { Trash2, Copy, Check } from "lucide-react"; import { Trash2, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { logger, type LogEntry } from "@/lib/logger"; import { logger, type LogEntry } from "@/lib/logger";
const levelColors: Record<string, string> = { const levelColors: Record<string, string> = {
info: "text-blue-500", info: "text-blue-500",
success: "text-green-500", success: "text-green-500",
warning: "text-yellow-500", warning: "text-yellow-500",
error: "text-red-500", error: "text-red-500",
debug: "text-gray-500", debug: "text-gray-500",
}; };
function formatTime(date: Date): string { function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", { return date.toLocaleTimeString("en-US", {
hour12: false, hour12: false,
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit", 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());
}); });
setLogs(logger.getLogs()); }
return () => { export function DebugLoggerPage() {
unsubscribe(); const [logs, setLogs] = useState<LogEntry[]>([]);
const [copied, setCopied] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const unsubscribe = logger.subscribe(() => {
setLogs(logger.getLogs());
});
setLogs(logger.getLogs());
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
}; };
}, []); const handleCopy = async () => {
const logText = logs
useEffect(() => { .map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
if (scrollRef.current) { .join("\n");
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; try {
} await navigator.clipboard.writeText(logText);
}, [logs]); setCopied(true);
setTimeout(() => setCopied(false), 500);
const handleClear = () => { }
logger.clear(); catch (err) {
}; console.error("Failed to copy logs:", err);
}
const handleCopy = async () => { };
const logText = logs return (<div className="space-y-6">
.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) {
console.error("Failed to copy logs:", err);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1> <h1 className="text-2xl font-bold">Debug Logs</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
variant="outline" {copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
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 Copy
</Button> </Button>
<Button <Button variant="outline" size="sm" className="gap-1.5" onClick={handleClear} disabled={logs.length === 0}>
variant="outline" <Trash2 className="h-4 w-4"/>
size="sm"
className="gap-1.5"
onClick={handleClear}
disabled={logs.length === 0}
>
<Trash2 className="h-4 w-4" />
Clear Clear
</Button> </Button>
</div> </div>
</div> </div>
<div <div ref={scrollRef} className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs">
ref={scrollRef} {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">
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"> <span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}] [{formatTime(log.timestamp)}]
</span> </span>
@@ -103,10 +75,7 @@ export function DebugLoggerPage() {
[{log.level}] [{log.level}]
</span> </span>
<span className="break-all">{log.message}</span> <span className="break-all">{log.message}</span>
</div> </div>)))}
))
)}
</div> </div>
</div> </div>);
);
} }
+13 -14
View File
@@ -1,30 +1,29 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react"; import { StopCircle } from "lucide-react";
interface DownloadProgressProps { interface DownloadProgressProps {
progress: number; progress: number;
currentTrack: { name: string; artists: string } | null; currentTrack: {
onStop: () => void; name: string;
artists: string;
} | null;
onStop: () => void;
} }
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) { export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress)); const clampedProgress = Math.min(100, Math.max(0, progress));
return ( return (<div className="w-full space-y-2 mt-4">
<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Progress value={clampedProgress} className="h-2 flex-1" /> <Progress value={clampedProgress} className="h-2 flex-1"/>
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5"> <Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4" /> <StopCircle className="h-4 w-4"/>
Stop Stop
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{clampedProgress}% -{" "} {clampedProgress}% -{" "}
{currentTrack {currentTrack
? `${currentTrack.name} - ${currentTrack.artists}` ? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."} : "Preparing download..."}
</p> </p>
</div> </div>);
);
} }
@@ -2,47 +2,30 @@ import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData"; import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
import { Download, ChevronRight } from "lucide-react"; import { Download, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
interface DownloadProgressToastProps { interface DownloadProgressToastProps {
onClick: () => void; onClick: () => void;
} }
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) { export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
const progress = useDownloadProgress(); const progress = useDownloadProgress();
const queueInfo = useDownloadQueueData(); const queueInfo = useDownloadQueueData();
const hasActiveDownloads = queueInfo.queue.some(item => item.status === "queued" || item.status === "downloading");
// Show indicator if there are any queued or downloading items if (!hasActiveDownloads) {
// Don't show for completed/failed/skipped only return null;
const hasActiveDownloads = queueInfo.queue.some( }
item => item.status === "queued" || item.status === "downloading" 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}>
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}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`} /> <Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
<div className="flex flex-col min-w-[80px]"> <div className="flex flex-col min-w-[80px]">
<p className="text-sm font-medium font-mono tabular-nums"> <p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB {progress.mb_downloaded.toFixed(2)} MB
</p> </p>
{progress.speed_mbps > 0 && ( {progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps.toFixed(2)} MB/s {progress.speed_mbps.toFixed(2)} MB/s
</p> </p>)}
)}
</div> </div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" /> <ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
</div> </div>
</Button> </Button>
</div> </div>);
);
} }
+130 -186
View File
@@ -1,194 +1,158 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react"; import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App"; import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models"; import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps { interface DownloadQueueProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>( const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
new backend.DownloadQueueInfo({ is_downloading: false,
is_downloading: false, queue: [],
queue: [], current_speed: 0,
current_speed: 0, total_downloaded: 0,
total_downloaded: 0, session_start_time: 0,
session_start_time: 0, queued_count: 0,
queued_count: 0, completed_count: 0,
completed_count: 0, failed_count: 0,
failed_count: 0, skipped_count: 0,
skipped_count: 0, }));
}) useEffect(() => {
); if (!isOpen)
return;
useEffect(() => { const fetchQueue = async () => {
if (!isOpen) return; try {
const info = await GetDownloadQueue();
const fetchQueue = async () => { setQueueInfo(info);
try { }
const info = await GetDownloadQueue(); catch (error) {
setQueueInfo(info); console.error("Failed to get download queue:", error);
} catch (error) { }
console.error("Failed to get download queue:", error); };
} fetchQueue();
const interval = setInterval(fetchQueue, 500);
return () => clearInterval(interval);
}, [isOpen]);
const handleClearHistory = async () => {
try {
await ClearCompletedDownloads();
const info = await GetDownloadQueue();
setQueueInfo(info);
}
catch (error) {
console.error("Failed to clear history:", error);
}
}; };
const getStatusIcon = (status: string) => {
// Initial fetch switch (status) {
fetchQueue(); case "downloading":
return <Download className="h-4 w-4 text-blue-500 animate-bounce"/>;
// Poll every 500ms when dialog is open case "completed":
const interval = setInterval(fetchQueue, 500); return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
case "failed":
return () => clearInterval(interval); return <XCircle className="h-4 w-4 text-red-500"/>;
}, [isOpen]); case "skipped":
return <FileCheck className="h-4 w-4 text-yellow-500"/>;
const handleClearHistory = async () => { case "queued":
try { return <Clock className="h-4 w-4 text-muted-foreground"/>;
await ClearCompletedDownloads(); default:
// Refetch immediately to update UI return null;
const info = await GetDownloadQueue(); }
setQueueInfo(info);
} catch (error) {
console.error("Failed to clear history:", error);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "downloading":
return <Download className="h-4 w-4 text-blue-500 animate-bounce" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "skipped":
return <FileCheck className="h-4 w-4 text-yellow-500" />;
case "queued":
return <Clock className="h-4 w-4 text-muted-foreground" />;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
downloading: "default",
completed: "outline",
failed: "destructive",
skipped: "secondary",
queued: "outline",
}; };
const getStatusBadge = (status: string) => {
return ( const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
<Badge variant={variants[status] || "outline"} className="text-xs"> downloading: "default",
completed: "outline",
failed: "destructive",
skipped: "secondary",
queued: "outline",
};
return (<Badge variant={variants[status] || "outline"} className="text-xs">
{status} {status}
</Badge> </Badge>);
); };
}; const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0)
// Format session duration return "—";
const formatDuration = (startTimestamp: number) => { const now = Math.floor(Date.now() / 1000);
if (startTimestamp === 0) return "—"; const durationSeconds = now - startTimestamp;
const now = Math.floor(Date.now() / 1000); const hours = Math.floor(durationSeconds / 3600);
const durationSeconds = now - startTimestamp; const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
const hours = Math.floor(durationSeconds / 3600); if (hours > 0) {
const minutes = Math.floor((durationSeconds % 3600) / 60); return `${hours}h ${minutes}m ${seconds}s`;
const seconds = durationSeconds % 60; }
else if (minutes > 0) {
if (hours > 0) { return `${minutes}m ${seconds}s`;
return `${hours}h ${minutes}m ${seconds}s`; }
} else if (minutes > 0) { else {
return `${minutes}m ${seconds}s`; return `${seconds}s`;
} 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"> <DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0"> <DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle> <DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && ( {(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}>
<Button <Trash2 className="h-3 w-3"/>
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={handleClearHistory}
>
<Trash2 className="h-3 w-3" />
Clear History Clear History
</Button> </Button>)}
)} <Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
<Button <X className="h-4 w-4"/>
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-muted"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
{/* Queue Status */}
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" /> <Clock className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Queued:</span> <span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span> <span className="font-semibold">{queueInfo.queued_count}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" /> <CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
<span className="text-muted-foreground">Completed:</span> <span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span> <span className="font-semibold">{queueInfo.completed_count}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500" /> <FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
<span className="text-muted-foreground">Skipped:</span> <span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span> <span className="font-semibold">{queueInfo.skipped_count}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500" /> <XCircle className="h-3.5 w-3.5 text-red-500"/>
<span className="text-muted-foreground">Failed:</span> <span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span> <span className="font-semibold">{queueInfo.failed_count}</span>
</div> </div>
</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-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" /> <HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Downloaded:</span> <span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono"> <span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"} {queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground" /> <Zap className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Speed:</span> <span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono"> <span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading {queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s` ? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"} : "—"}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground" /> <Timer className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span> <span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono"> <span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"} {queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
@@ -198,20 +162,13 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
</DialogHeader> </DialogHeader>
{/* Download Queue List */}
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar"> <div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4"> <div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? ( {queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<div className="text-center py-12 text-muted-foreground"> <Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<Download className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>No downloads in queue</p> <p>No downloads in queue</p>
</div> </div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
) : (
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="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div> <div className="mt-1">{getStatusIcon(item.status)}</div>
@@ -227,61 +184,48 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
{getStatusBadge(item.status)} {getStatusBadge(item.status)}
</div> </div>
{/* Info for downloading items */}
{item.status === "downloading" && ( {item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span> <span>
{item.progress > 0 {item.progress > 0
? `${item.progress.toFixed(2)} MB` ? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0 : queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..." ? "Downloading..."
: "Starting..."} : "Starting..."}
</span> </span>
<span> <span>
{item.speed > 0 {item.speed > 0
? `${item.speed.toFixed(2)} MB/s` ? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0 : queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s` ? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"} : "—"}
</span> </span>
</div> </div>)}
)}
{/* Completed info */}
{item.status === "completed" && ( {item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<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> <span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div> </div>)}
)}
{/* Skipped info */}
{item.status === "skipped" && ( {item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
<div className="mt-1.5 text-xs text-muted-foreground">
File already exists File already exists
</div> </div>)}
)}
{/* Error message */}
{item.status === "failed" && item.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">
<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} {item.error_message}
</div> </div>)}
)}
{/* File path for completed/skipped */}
{(item.status === "completed" || item.status === "skipped") && item.file_path && ( {(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path} {item.file_path}
</div> </div>)}
)}
</div> </div>
</div> </div>
</div> </div>)))}
))
)}
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>);
);
} }
+73 -68
View File
@@ -1,91 +1,96 @@
import { X } from "lucide-react"; import { X, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
export interface HistoryItem { export interface HistoryItem {
id: string; id: string;
url: string; url: string;
type: "track" | "album" | "playlist" | "artist"; type: "track" | "album" | "playlist" | "artist";
name: string; name: string;
artist: string; artist: string;
image: string; image: string;
timestamp: number; timestamp: number;
} }
interface FetchHistoryProps { interface FetchHistoryProps {
history: HistoryItem[]; history: HistoryItem[];
onSelect: (item: HistoryItem) => void; onSelect: (item: HistoryItem) => void;
onRemove: (id: string) => void; onRemove: (id: string) => void;
} }
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) { export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
if (history.length === 0) return null; if (history.length === 0)
return null;
const getTypeLabel = (type: string) => { const getTypeLabel = (type: string) => {
switch (type) { switch (type) {
case "track": case "track":
return "Track"; return "Track";
case "album": case "album":
return "Album"; return "Album";
case "playlist": case "playlist":
return "Playlist"; return "Playlist";
case "artist": case "artist":
return "Artist"; return "Artist";
default: default:
return type; return type;
} }
}; };
const getTypeIcon = (type: string) => {
return ( switch (type) {
<div className="space-y-2"> case "track":
<span className="text-sm text-muted-foreground">Recent Fetches</span> 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"> <div className="flex gap-2 overflow-x-auto pb-2 pt-2">
{history.map((item) => ( {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)}>
<div <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) => {
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(); e.stopPropagation();
onRemove(item.id); onRemove(item.id);
}} }}>
> <X className="h-3 w-3 text-red-900" strokeWidth={3}/>
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
</button> </button>
<div className="p-2"> <div className="p-2">
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted"> <div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
{item.image ? ( {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">
<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 No Image
</div> </div>)}
)}
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-xs font-medium truncate" title={item.name}> <p className="text-xs font-medium truncate" title={item.name}>
{item.name} {item.name}
</p> </p>
<p <p className="text-xs text-muted-foreground truncate" title={item.artist}>
className="text-xs text-muted-foreground truncate"
title={item.artist}
>
{item.artist} {item.artist}
</p> </p>
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground"> {(() => {
{getTypeLabel(item.type)} const IconComponent = getTypeIcon(item.type);
</span> 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>);
})()}
</div> </div>
</div> </div>
</div> </div>))}
))}
</div> </div>
</div> </div>);
);
} }
File diff suppressed because it is too large Load Diff
+13 -37
View File
@@ -1,66 +1,42 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/relative-time"; import { formatRelativeTime } from "@/lib/relative-time";
interface HeaderProps { interface HeaderProps {
version: string; version: string;
hasUpdate: boolean; hasUpdate: boolean;
releaseDate?: string | null; releaseDate?: string | null;
} }
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) { export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return ( return (<div className="relative">
<div className="relative">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<img <img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
src="/icon.svg" <h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
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 SpotiFLAC
</h1> </h1>
<div className="relative"> <div className="relative">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Badge variant="default" asChild> <Badge variant="default" asChild>
<button <button type="button" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")} className="cursor-pointer hover:opacity-80 transition-opacity">
type="button"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
v{version} v{version}
</button> </button>
</Badge> </Badge>
</TooltipTrigger> </TooltipTrigger>
{hasUpdate && releaseDate && ( {hasUpdate && releaseDate && (<TooltipContent>
<TooltipContent>
<p>{formatRelativeTime(releaseDate)}</p> <p>{formatRelativeTime(releaseDate)}</p>
</TooltipContent> </TooltipContent>)}
)}
</Tooltip> </Tooltip>
{hasUpdate && ( {hasUpdate && (<span className="absolute -top-1 -right-1 flex h-3 w-3">
<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="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 className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span> </span>)}
)}
</div> </div>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required. Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required.
</p> </p>
</div> </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;
export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( }) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path> <path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg> </svg>);
); export const QobuzIcon = ({ className = "w-4 h-4" }: {
className?: string;
export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( }) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path> <path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg> </svg>);
); export const AmazonIcon = ({ className = "w-4 h-4" }: {
className?: string;
export const AmazonIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( }) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<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="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg> </svg>);
);
+96 -212
View File
@@ -7,135 +7,94 @@ import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface PlaylistInfoProps { interface PlaylistInfoProps {
playlistInfo: { playlistInfo: {
owner: { owner: {
name: string; name: string;
display_name: string; display_name: string;
images: string; images: string;
};
tracks: {
total: number;
};
followers: {
total: number;
};
cover?: string;
description?: string;
}; };
tracks: { trackList: TrackMetadata[];
total: number; searchQuery: string;
}; sortBy: string;
followers: { selectedTracks: string[];
total: number; downloadedTracks: Set<string>;
}; failedTracks: Set<string>;
}; skippedTracks: Set<string>;
trackList: TrackMetadata[]; downloadingTrack: string | null;
searchQuery: string; isDownloading: boolean;
sortBy: string; bulkDownloadType: "all" | "selected" | null;
selectedTracks: string[]; downloadProgress: number;
downloadedTracks: Set<string>; currentDownloadInfo: {
failedTracks: Set<string>; name: string;
skippedTracks: Set<string>; artists: string;
downloadingTrack: string | null; } | null;
isDownloading: boolean; currentPage: number;
bulkDownloadType: "all" | "selected" | null; itemsPerPage: number;
downloadProgress: number; downloadedLyrics?: Set<string>;
currentDownloadInfo: { name: string; artists: string } | null; failedLyrics?: Set<string>;
currentPage: number; skippedLyrics?: Set<string>;
itemsPerPage: number; downloadingLyricsTrack?: string | null;
// Lyrics props checkingAvailabilityTrack?: string | null;
downloadedLyrics?: Set<string>; availabilityMap?: Map<string, TrackAvailability>;
failedLyrics?: Set<string>; downloadedCovers?: Set<string>;
skippedLyrics?: Set<string>; failedCovers?: Set<string>;
downloadingLyricsTrack?: string | null; skippedCovers?: Set<string>;
// Availability props downloadingCoverTrack?: string | null;
checkingAvailabilityTrack?: string | null; isBulkDownloadingCovers?: boolean;
availabilityMap?: Map<string, TrackAvailability>; isBulkDownloadingLyrics?: boolean;
// Cover props onSearchChange: (value: string) => void;
downloadedCovers?: Set<string>; onSortChange: (value: string) => void;
failedCovers?: Set<string>; onToggleTrack: (isrc: string) => void;
skippedCovers?: Set<string>; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
downloadingCoverTrack?: string | null; 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;
isBulkDownloadingCovers?: boolean; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
isBulkDownloadingLyrics?: boolean; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onSearchChange: (value: string) => void; onCheckAvailability?: (spotifyId: string) => void;
onSortChange: (value: string) => void; onDownloadAllLyrics?: () => void;
onToggleTrack: (isrc: string) => void; onDownloadAllCovers?: () => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onDownloadAll: () => 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; onDownloadSelected: () => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onStopDownload: () => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onOpenFolder: () => void;
onCheckAvailability?: (spotifyId: string) => void; onPageChange: (page: number) => void;
onDownloadAllLyrics?: () => void; onAlbumClick: (album: {
onDownloadAllCovers?: () => void; id: string;
onDownloadAll: () => void; name: string;
onDownloadSelected: () => void; external_urls: string;
onStopDownload: () => void; }) => void;
onOpenFolder: () => void; onArtistClick: (artist: {
onPageChange: (page: number) => void; id: string;
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void; name: string;
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void; external_urls: string;
onTrackClick: (track: TrackMetadata) => void; }) => 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) {
export function PlaylistInfo({ return (<div className="space-y-6">
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> <Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{playlistInfo.owner.images && ( {playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
<img
src={playlistInfo.owner.images}
alt={playlistInfo.owner.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium">Playlist</p> <p className="text-sm font-medium">Playlist</p>
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2> <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 text-sm">
<span className="font-medium">{playlistInfo.owner.display_name}</span> <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></span>
<span> <span>
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"} {playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
@@ -146,121 +105,46 @@ export function PlaylistInfo({
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}> <Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? ( {isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download All Download All
</Button> </Button>
{selectedTracks.length > 0 && ( {selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
<Button {isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
onClick={onDownloadSelected}
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </Button>)}
)} {onDownloadAllLyrics && (<Tooltip>
{onDownloadAllLyrics && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
onClick={onDownloadAllLyrics} {isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
variant="outline"
disabled={isBulkDownloadingLyrics}
>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download All Lyrics</p> <p>Download All Lyrics</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {onDownloadAllCovers && (<Tooltip>
{onDownloadAllCovers && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
onClick={onDownloadAllCovers} {isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download All Covers</p> <p>Download All Covers</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
{downloadedTracks.size > 0 && ( <FolderOpen className="h-4 w-4"/>
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder Open Folder
</Button> </Button>)}
)}
</div> </div>
{isDownloading && ( {isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="space-y-4"> <div className="space-y-4">
<SearchAndSort <SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
searchQuery={searchQuery} <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}/>
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> </div>);
);
} }
+17 -41
View File
@@ -1,50 +1,25 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search, ArrowUpDown, XCircle } from "lucide-react"; import { Search, ArrowUpDown, XCircle } from "lucide-react";
interface SearchAndSortProps { interface SearchAndSortProps {
searchQuery: string; searchQuery: string;
sortBy: string; sortBy: string;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
} }
export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChange, }: SearchAndSortProps) {
export function SearchAndSort({ return (<div className="flex gap-2">
searchQuery,
sortBy,
onSearchChange,
onSortChange,
}: SearchAndSortProps) {
return (
<div className="flex gap-2">
<div className="relative flex-1"> <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" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input <Input placeholder="Search tracks..." value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} className="pl-10 pr-8"/>
placeholder="Search tracks..." {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("")}>
value={searchQuery} <XCircle className="h-4 w-4"/>
onChange={(e) => onSearchChange(e.target.value)} </button>)}
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>
)}
</div> </div>
<Select value={sortBy} onValueChange={onSortChange}> <Select value={sortBy} onValueChange={onSortChange}>
<SelectTrigger className="w-[200px] gap-1.5"> <SelectTrigger className="w-[200px] gap-1.5">
<ArrowUpDown className="h-4 w-4" /> <ArrowUpDown className="h-4 w-4"/>
<SelectValue placeholder="Sort by" /> <SelectValue placeholder="Sort by"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="default">Default</SelectItem> <SelectItem value="default">Default</SelectItem>
@@ -54,10 +29,11 @@ export function SearchAndSort({
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem> <SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem> <SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</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="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem> <SelectItem value="not-downloaded">Not Downloaded</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>);
);
} }
+351 -72
View File
@@ -1,94 +1,373 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context"; import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label"; import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { CloudDownload, Info, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory"; import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } 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 { interface SearchBarProps {
url: string; url: string;
loading: boolean; loading: boolean;
onUrlChange: (url: string) => void; onUrlChange: (url: string) => void;
onFetch: () => void; onFetch: () => void;
history: HistoryItem[]; onFetchUrl: (url: string) => Promise<void>;
onHistorySelect: (item: HistoryItem) => void; history: HistoryItem[];
onHistoryRemove: (id: string) => void; onHistorySelect: (item: HistoryItem) => void;
hasResult: boolean; onHistoryRemove: (id: string) => void;
hasResult: boolean;
searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void;
} }
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) {
export function SearchBar({ const [searchQuery, setSearchQuery] = useState("");
url, const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
loading, const [isSearching, setIsSearching] = useState(false);
onUrlChange, const [isLoadingMore, setIsLoadingMore] = useState(false);
onFetch, const [lastSearchedQuery, setLastSearchedQuery] = useState("");
history, const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
onHistorySelect, const [recentSearches, setRecentSearches] = useState<string[]>([]);
onHistoryRemove, const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
hasResult, tracks: false,
}: SearchBarProps) { albums: false,
return ( artists: false,
<div className="space-y-3"> playlists: false,
});
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
try {
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
if (saved) {
setRecentSearches(JSON.parse(saved));
}
}
catch (error) {
console.error("Failed to load recent searches:", error);
}
}, []);
const saveRecentSearch = (query: string) => {
const trimmed = query.trim();
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) {
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) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
useEffect(() => {
if (!searchMode || !searchQuery.trim()) {
return;
}
if (searchQuery.trim() === lastSearchedQuery) {
return;
}
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
setSearchResults(results);
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
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,
});
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 {
setIsSearching(false);
}
}, 400);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, searchMode, lastSearchedQuery]);
const handleLoadMore = async () => {
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({
query: lastSearchedQuery,
search_type: typeMap[activeTab],
limit: SEARCH_LIMIT,
offset: currentCount,
});
if (moreResults.length > 0) {
setSearchResults((prev) => {
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,
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
});
return updated;
});
}
setHasMore((prev) => ({
...prev,
[activeTab]: moreResults.length === SEARCH_LIMIT,
}));
}
catch (error) {
console.error("Load more failed:", error);
}
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 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0);
const getTabCount = (tab: ResultTab): number => {
if (!searchResults)
return 0;
switch (tab) {
case "tracks": return searchResults.tracks.length;
case "albums": return searchResults.albums.length;
case "artists": return searchResults.artists.length;
case "playlists": return searchResults.playlists.length;
}
};
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">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
<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
? "bg-background text-foreground shadow-sm"
: "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
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")}>
<Search className="h-3.5 w-3.5"/>
Search
</button>
</div>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" /> <Info className="h-4 w-4 text-muted-foreground cursor-help"/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p> {!searchMode ? (<>
<p className="mt-1">Note: Playlist must be public (not private)</p> <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>)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<InputWithContext {!searchMode ? (<>
id="spotify-url" <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"/>
placeholder="https://open.spotify.com/..." {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("")}>
value={url} <XCircle className="h-4 w-4"/>
onChange={(e) => onUrlChange(e.target.value)} </button>)}
onKeyDown={(e) => e.key === "Enter" && onFetch()} </>) : (<>
className="pr-8" <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={() => {
{url && ( setSearchQuery("");
<button setSearchResults(null);
type="button" setLastSearchedQuery("");
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>)}
<XCircle className="h-4 w-4" /> </>)}
</button>
)}
</div> </div>
<Button onClick={onFetch} disabled={loading}>
{loading ? ( {!searchMode && (<Button onClick={onFetch} disabled={loading}>
<> {loading ? (<>
<Spinner /> <Spinner />
Fetching... Fetching...
</> </>) : (<>
) : ( <CloudDownload className="h-4 w-4"/>
<> Fetch
<CloudDownload className="h-4 w-4" /> </>)}
Fetch </Button>)}
</>
)}
</Button>
</div> </div>
</div> </div>
{!hasResult && (
<FetchHistory {!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
history={history}
onSelect={onHistorySelect}
onRemove={onHistoryRemove} {searchMode && (<div className="space-y-4">
/>
)} {!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
</div> <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)}>
<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) => {
e.stopPropagation();
removeRecentSearch(query);
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
</div>))}
</div>
</div>)}
{isSearching && (<div className="flex items-center justify-center py-8">
<Spinner />
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>)}
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
No results found for "{searchQuery}"
</div>)}
{!isSearching && hasAnyResults && (<>
<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
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground")}>
{tab.label} ({count})
</button>);
})}
</div>
<div className="grid gap-2">
{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>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{formatDuration(track.duration_ms || 0)}
</span>
</button>))}
{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.release_date || ""}
</span>
</button>))}
{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>))}
{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 || ""}
</p>
</div>
</button>))}
</div>
{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>);
} }
+172 -249
View File
@@ -1,157 +1,138 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context"; import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react"; import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
import { import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App"; import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
// Service Icons
const TidalIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path> <path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path> <path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg> </svg>);
); const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path> <path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path> <path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg> </svg>);
); const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
const AmazonIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path> <path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg> </svg>);
); interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
export function SettingsPage() { onResetRequest?: (resetFn: () => void) => void;
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings()); }
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings); export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [showResetConfirm, setShowResetConfirm] = useState(false); const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
useEffect(() => { const [showResetConfirm, setShowResetConfirm] = useState(false);
applyThemeMode(savedSettings.themeMode); const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
applyTheme(savedSettings.theme); const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); flushSync(() => {
const handleChange = () => { setTempSettings(freshSavedSettings);
if (savedSettings.themeMode === "auto") { setIsDark(document.documentElement.classList.contains('dark'));
applyThemeMode("auto"); });
}, []);
useEffect(() => {
if (onResetRequest) {
onResetRequest(resetToSaved);
}
}, [onResetRequest, resetToSaved]);
useEffect(() => {
onUnsavedChangesChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChangesChange]);
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme); applyTheme(savedSettings.theme);
} const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [savedSettings.themeMode, savedSettings.theme]);
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
saveSettings(settingsWithDefaults);
}
};
loadDefaults();
}, []);
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
onUnsavedChangesChange?.(false);
}; };
const handleReset = async () => {
mediaQuery.addEventListener("change", handleChange); const defaultSettings = await resetToDefaultSettings();
return () => mediaQuery.removeEventListener("change", handleChange); setTempSettings(defaultSettings);
}, [savedSettings.themeMode, savedSettings.theme]); setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
useEffect(() => { applyTheme(defaultSettings.theme);
applyThemeMode(tempSettings.themeMode); applyFont(defaultSettings.fontFamily);
applyTheme(tempSettings.theme); setShowResetConfirm(false);
applyFont(tempSettings.fontFamily); toast.success("Settings reset to default");
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
}
}; };
loadDefaults(); const handleBrowseFolder = async () => {
}, []); try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
const handleSave = () => { if (selectedPath && selectedPath.trim() !== "") {
saveSettings(tempSettings); setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
setSavedSettings(tempSettings); }
toast.success("Settings saved"); }
}; catch (error) {
console.error("Error selecting folder:", error);
const handleReset = async () => { toast.error(`Error selecting folder: ${error}`);
const defaultSettings = await resetToDefaultSettings(); }
setTempSettings(defaultSettings); };
setSavedSettings(defaultSettings); return (<div className="space-y-6">
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false);
toast.success("Settings reset to default");
};
const handleBrowseFolder = async () => {
try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
}
} catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">Settings</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4"> <div className="space-y-4">
{/* Download Path */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label> <Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<InputWithContext <InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
id="download-path"
value={tempSettings.downloadPath}
onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5"> <Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4"/>
Browse Browse
</Button> </Button>
</div> </div>
</div> </div>
{/* Theme Mode */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label> <Label htmlFor="theme-mode">Mode</Label>
<Select <Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
value={tempSettings.themeMode}
onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}
>
<SelectTrigger id="theme-mode"> <SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" /> <SelectValue placeholder="Select theme mode"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
@@ -161,77 +142,57 @@ export function SettingsPage() {
</Select> </Select>
</div> </div>
{/* Accent */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="theme">Accent</Label> <Label htmlFor="theme">Accent</Label>
<Select <Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
value={tempSettings.theme}
onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}
>
<SelectTrigger id="theme"> <SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" /> <SelectValue placeholder="Select a theme"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{themes.map((theme) => ( {themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span className="w-3 h-3 rounded-full border border-border" style={{
className="w-3 h-3 rounded-full border border-border" backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
style={{ }}/>
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}}
/>
{theme.label} {theme.label}
</span> </span>
</SelectItem> </SelectItem>))}
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Font */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="font">Font</Label> <Label htmlFor="font">Font</Label>
<Select <Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
value={tempSettings.fontFamily}
onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}
>
<SelectTrigger id="font"> <SelectTrigger id="font">
<SelectValue placeholder="Select a font" /> <SelectValue placeholder="Select a font"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FONT_OPTIONS.map((font) => ( {FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span> <span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
</SelectItem> </SelectItem>))}
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Sound Effects */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label> <Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch <Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
id="sfx-enabled"
checked={tempSettings.sfxEnabled}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
/>
</div> </div>
</div> </div>
{/* Right Column */}
<div className="space-y-4"> <div className="space-y-4">
{/* Source Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="downloader" className="text-sm">Source</Label> <Label htmlFor="downloader" className="text-sm">Source</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
value={tempSettings.downloader}
onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
>
<SelectTrigger id="downloader" className="h-9 w-fit"> <SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source" /> <SelectValue placeholder="Select a source"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
@@ -246,12 +207,8 @@ export function SettingsPage() {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{/* Quality dropdown for Tidal */}
{tempSettings.downloader === "tidal" && ( {tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}>
<Select
value={tempSettings.tidalQuality}
onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}
>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -259,14 +216,9 @@ export function SettingsPage() {
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem> <SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem> <SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>)}
)}
{/* Quality dropdown for Qobuz */} {tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
{tempSettings.downloader === "qobuz" && (
<Select
value={tempSettings.qobuzQuality}
onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}
>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -275,40 +227,40 @@ export function SettingsPage() {
<SelectItem value="7">FLAC 24-bit</SelectItem> <SelectItem value="7">FLAC 24-bit</SelectItem>
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem> <SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>)}
)}
{tempSettings.downloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={(value: "HI_RES") => setTempSettings((prev) => ({ ...prev, amazonQuality: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HI_RES">Hi-Res (24-bit/96kHz+)</SelectItem>
</SelectContent>
</Select>)}
</div> </div>
</div> </div>
{/* Embed Lyrics & Embed Max Quality Cover */}
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label> <Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch <Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
id="embed-lyrics"
checked={tempSettings.embedLyrics}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}
/>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label> <Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch <Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
id="embed-max-quality-cover"
checked={tempSettings.embedMaxQualityCover}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}
/>
</div> </div>
</div> </div>
<div className="border-t" /> <div className="border-t"/>
{/* Folder Structure */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label> <Label className="text-sm">Folder Structure</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
@@ -316,51 +268,37 @@ export function SettingsPage() {
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
value={tempSettings.folderPreset} const preset = FOLDER_PRESETS[value];
onValueChange={(value: FolderPreset) => { setTempSettings(prev => ({
const preset = FOLDER_PRESETS[value]; ...prev,
setTempSettings(prev => ({ folderPreset: value,
...prev, folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
folderPreset: value, }));
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template }}>
}));
}}
>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => ( {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.folderPreset === "custom" && ( {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"/>)}
<InputWithContext
value={tempSettings.folderTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
placeholder="{artist}/{album}"
className="h-9 text-sm flex-1"
/>
)}
</div> </div>
{tempSettings.folderTemplate && ( {tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
<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> 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>
<div className="border-t" /> <div className="border-t"/>
{/* Filename Format */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label> <Label className="text-sm">Filename Format</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
@@ -368,57 +306,43 @@ export function SettingsPage() {
</Tooltip> </Tooltip>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
value={tempSettings.filenamePreset} const preset = FILENAME_PRESETS[value];
onValueChange={(value: FilenamePreset) => { setTempSettings(prev => ({
const preset = FILENAME_PRESETS[value]; ...prev,
setTempSettings(prev => ({ filenamePreset: value,
...prev, filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
filenamePreset: value, }));
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template }}>
}));
}}
>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => ( {Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.filenamePreset === "custom" && ( {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"/>)}
<InputWithContext
value={tempSettings.filenameTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
placeholder="{track}. {title}"
className="h-9 text-sm flex-1"
/>
)}
</div> </div>
{tempSettings.filenameTemplate && ( {tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
<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> 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> </div>
</div> </div>
{/* Actions */}
<div className="flex gap-2 justify-between pt-4 border-t"> <div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5"> <Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4"/>
Reset to Default Reset to Default
</Button> </Button>
<Button onClick={handleSave} className="gap-1.5"> <Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" /> <Save className="h-4 w-4"/>
Save Changes Save Changes
</Button> </Button>
</div> </div>
{/* Reset Confirmation Dialog */}
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}> <Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden"> <DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader> <DialogHeader>
@@ -433,6 +357,5 @@ export function SettingsPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>);
);
} }
+33 -86
View File
@@ -1,40 +1,28 @@
import { FileMusic, FilePen } from "lucide-react";
import { HomeIcon } from "@/components/ui/home"; import { HomeIcon } from "@/components/ui/home";
import { SettingsIcon } from "@/components/ui/settings"; import { SettingsIcon } from "@/components/ui/settings";
import { ActivityIcon } from "@/components/ui/activity"; import { ActivityIcon } from "@/components/ui/activity";
import { TerminalIcon } from "@/components/ui/terminal"; 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 { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks"; import { BlocksIcon } from "@/components/ui/blocks";
import { CoffeeIcon } from "@/components/ui/coffee"; import { CoffeeIcon } from "@/components/ui/coffee";
import { import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager"; export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
interface SidebarProps { interface SidebarProps {
currentPage: PageType; currentPage: PageType;
onPageChange: (page: PageType) => void; onPageChange: (page: PageType) => void;
} }
export function Sidebar({ currentPage, onPageChange }: SidebarProps) { export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return ( 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="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"> <div className="flex flex-col gap-2 flex-1">
{/* Home */}
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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")}>
variant={currentPage === "main" ? "secondary" : "ghost"} <HomeIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("main")}
>
<HomeIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -42,16 +30,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Settings */}
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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")}>
variant={currentPage === "settings" ? "secondary" : "ghost"} <SettingsIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("settings")}
>
<SettingsIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -59,16 +42,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Audio Analysis */}
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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")}>
variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} <ActivityIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("audio-analysis")}
>
<ActivityIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -76,16 +54,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Audio Converter - using lucide icon (no animated version) */}
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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")}>
variant={currentPage === "audio-converter" ? "secondary" : "ghost"} <FileMusicIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("audio-converter")}
>
<FileMusic className="h-5 w-5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -93,16 +66,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* File Manager - using lucide icon (no animated version) */}
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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")}>
variant={currentPage === "file-manager" ? "secondary" : "ghost"} <FilePenIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("file-manager")}
>
<FilePen className="h-5 w-5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -110,16 +78,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Debug */}
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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")}>
variant={currentPage === "debug" ? "secondary" : "ghost"} <TerminalIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => onPageChange("debug")}
>
<TerminalIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -128,17 +91,12 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</Tooltip> </Tooltip>
</div> </div>
{/* Bottom icons */}
<div className="mt-auto flex flex-col gap-2"> <div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?labels=bug&body=%23%23%23%20Problem%0AExplain%20the%20issue%20briefly.%0A%0A%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%20Spotify%20URL%0APaste%20the%20link%20here.%0A%0A%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS")}>
variant="ghost" <GithubIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
>
<GithubIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -147,13 +105,8 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://exyezed.cc/")}>
variant="ghost" <BlocksIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://exyezed.cc/")}
>
<BlocksIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -162,20 +115,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
variant="ghost" <CoffeeIcon size={20}/>
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
>
<CoffeeIcon size={20} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Support me on Ko-fi</p> <p>Every coffee helps me keep going</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>);
);
} }
+188 -284
View File
@@ -1,289 +1,193 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { SpectrumData } from "@/types/api"; import type { SpectrumData } from "@/types/api";
interface SpectrumVisualizationProps { interface SpectrumVisualizationProps {
sampleRate: number; sampleRate: number;
bitsPerSample: number; bitsPerSample: number;
duration: number; duration: number;
spectrumData?: SpectrumData; spectrumData?: SpectrumData;
} }
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
export function SpectrumVisualization({ const canvasRef = useRef<HTMLCanvasElement>(null);
sampleRate, useEffect(() => {
bitsPerSample, const canvas = canvasRef.current;
duration, if (!canvas)
spectrumData, return;
}: SpectrumVisualizationProps) { const ctx = canvas.getContext("2d");
const canvasRef = useRef<HTMLCanvasElement>(null); if (!ctx)
return;
useEffect(() => { const width = canvas.width;
const canvas = canvasRef.current; const height = canvas.height;
if (!canvas) return; const marginLeft = 70;
const marginRight = 70;
const ctx = canvas.getContext("2d"); const marginTop = 30;
if (!ctx) return; const marginBottom = 65;
const plotWidth = width - marginLeft - marginRight;
const width = canvas.width; const plotHeight = height - marginTop - marginBottom;
const height = canvas.height; ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
// Calculate margins for labels const nyquistFreq = sampleRate / 2;
const marginLeft = 70; // More space for Frequency label if (spectrumData) {
const marginRight = 70; // Space for color bar drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
const marginTop = 30; // More space at top }
const marginBottom = 65; // More space at bottom for Time label drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
const plotWidth = width - marginLeft - marginRight; }, [sampleRate, bitsPerSample, duration, spectrumData]);
const plotHeight = height - marginTop - marginBottom; const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
const timeSlices = spectrum.time_slices;
// Black background if (timeSlices.length === 0)
ctx.fillStyle = "#000000"; return;
ctx.fillRect(0, 0, width, height); const freqBins = timeSlices[0].magnitudes.length;
const nyquistFreq = spectrum.max_freq;
// Calculate Nyquist frequency let minDB = 0;
const nyquistFreq = sampleRate / 2; let maxDB = -200;
timeSlices.forEach((slice) => {
if (spectrumData) { slice.magnitudes.forEach((db) => {
drawRealSpectrum( if (db > maxDB)
ctx, maxDB = db;
marginLeft, if (db < minDB && db > -200)
marginTop, minDB = db;
plotWidth, });
plotHeight, });
spectrumData 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++) {
// Draw axes, labels, and color bar const slice = timeSlices[t];
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate); const xPos = x + (t / timeSlices.length) * width;
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight); for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
}, [sampleRate, bitsPerSample, duration, spectrumData]); const db = slice.magnitudes[f];
const freq = (f / freqBins) * nyquistFreq;
const drawRealSpectrum = ( const freqRatio = freq / nyquistFreq;
ctx: CanvasRenderingContext2D, const yPos = y + height - (freqRatio * height);
x: number, const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
y: number, const nextFreqRatio = nextFreq / nyquistFreq;
width: number, const nextYPos = y + height - (nextFreqRatio * height);
height: number, const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
spectrum: SpectrumData const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
) => { const color = getSpekColor(intensity);
const timeSlices = spectrum.time_slices; ctx.fillStyle = color;
if (timeSlices.length === 0) return; ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
}
const freqBins = timeSlices[0].magnitudes.length; }
const nyquistFreq = spectrum.max_freq; };
const getSpekColor = (intensity: number): string => {
// Find min/max dB values if (intensity < 0.08) {
let minDB = 0; const t = intensity / 0.08;
let maxDB = -200; return `rgb(0, 0, ${Math.floor(t * 80)})`;
}
timeSlices.forEach((slice) => { else if (intensity < 0.18) {
slice.magnitudes.forEach((db) => { const t = (intensity - 0.08) / 0.10;
if (db > maxDB) maxDB = db; return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
if (db < minDB && db > -200) minDB = db; }
}); 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)})`;
// Clamp range for better visualization }
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range else if (intensity < 0.40) {
const dbRange = maxDB - minDB; const t = (intensity - 0.28) / 0.12;
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
const sliceWidth = Math.ceil(width / timeSlices.length); }
else if (intensity < 0.52) {
for (let t = 0; t < timeSlices.length; t++) { const t = (intensity - 0.40) / 0.12;
const slice = timeSlices[t]; return `rgb(255, ${Math.floor(t * 100)}, 0)`;
const xPos = x + (t / timeSlices.length) * width; }
else if (intensity < 0.65) {
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) { const t = (intensity - 0.52) / 0.13;
const db = slice.magnitudes[f]; return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
}
// Linear frequency scale else if (intensity < 0.78) {
const freq = (f / freqBins) * nyquistFreq; const t = (intensity - 0.65) / 0.13;
const freqRatio = freq / nyquistFreq; return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
}
const yPos = y + height - (freqRatio * height); else if (intensity < 0.90) {
const t = (intensity - 0.78) / 0.12;
// Calculate bin height return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
const nextFreq = ((f + 1) / freqBins) * nyquistFreq; }
const nextFreqRatio = nextFreq / nyquistFreq; else {
const nextYPos = y + height - (nextFreqRatio * height); const t = (intensity - 0.90) / 0.10;
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1); }
};
// Normalize intensity const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange)); ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
const color = getSpekColor(intensity); ctx.textAlign = "right";
ctx.fillStyle = color; ctx.textBaseline = "middle";
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight); const freqLabels = generateFreqLabels(nyquistFreq);
} freqLabels.forEach(freq => {
} if (freq <= nyquistFreq) {
}; const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
// Vibrant color scheme like Spek - NGEJERENG! const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
const getSpekColor = (intensity: number): string => { ctx.fillText(label, x - 8, yPos);
if (intensity < 0.08) { }
// Black to deep blue });
const t = intensity / 0.08; ctx.fillText("0", x - 8, y + height);
return `rgb(0, 0, ${Math.floor(t * 80)})`; ctx.textAlign = "center";
} else if (intensity < 0.18) { ctx.textBaseline = "top";
// Deep blue to bright blue const timeStep = getTimeStep(duration);
const t = (intensity - 0.08) / 0.10; for (let t = 0; t <= duration; t += timeStep) {
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`; const xPos = x + (t / duration) * width;
} else if (intensity < 0.28) { ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
// Blue to magenta/purple }
const t = (intensity - 0.18) / 0.10; ctx.fillStyle = "#FFFFFF";
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`; ctx.font = "13px Arial";
} else if (intensity < 0.40) { ctx.save();
// Magenta to bright red ctx.translate(12, y + height / 2);
const t = (intensity - 0.28) / 0.12; ctx.rotate(-Math.PI / 2);
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`; ctx.textAlign = "center";
} else if (intensity < 0.52) { ctx.fillText("Frequency (Hz)", 0, 0);
// Red to orange-red ctx.restore();
const t = (intensity - 0.40) / 0.12; ctx.textAlign = "center";
return `rgb(255, ${Math.floor(t * 100)}, 0)`; ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
} else if (intensity < 0.65) { ctx.textAlign = "right";
// Orange-red to bright orange ctx.fillStyle = "#CCCCCC";
const t = (intensity - 0.52) / 0.13; ctx.font = "12px Arial";
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`; ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
} else if (intensity < 0.78) { };
// Orange to yellow-orange const generateFreqLabels = (nyquistFreq: number): number[] => {
const t = (intensity - 0.65) / 0.13; if (nyquistFreq <= 24000) {
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`; return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
} else if (intensity < 0.90) { }
// Yellow-orange to bright yellow else if (nyquistFreq <= 48000) {
const t = (intensity - 0.78) / 0.12; return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`; }
} else { else if (nyquistFreq <= 96000) {
// Yellow to white (hottest) return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
const t = (intensity - 0.90) / 0.10; }
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`; else {
} return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
}; }
};
const drawAxesAndLabels = ( const getTimeStep = (duration: number): number => {
ctx: CanvasRenderingContext2D, if (duration <= 60)
x: number, return 15;
y: number, if (duration <= 120)
width: number, return 30;
height: number, if (duration <= 300)
nyquistFreq: number, return 30;
duration: number, if (duration <= 600)
sampleRate: number return 60;
) => { return 60;
// Frequency labels on Y-axis };
ctx.fillStyle = "#CCCCCC"; const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
ctx.font = "12px Arial"; for (let i = 0; i < height; i++) {
ctx.textAlign = "right"; const intensity = 1 - (i / height);
ctx.textBaseline = "middle"; const color = getSpekColor(intensity);
ctx.fillStyle = color;
// Generate frequency labels based on Nyquist ctx.fillRect(x, y + i, width, 1);
const freqLabels = generateFreqLabels(nyquistFreq); }
ctx.strokeStyle = "#666666";
freqLabels.forEach(freq => { ctx.lineWidth = 1;
if (freq <= nyquistFreq) { ctx.strokeRect(x, y, width, height);
const freqRatio = freq / nyquistFreq; ctx.fillStyle = "#FFFFFF";
const yPos = y + height - (freqRatio * height); ctx.font = "11px Arial";
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`; ctx.textAlign = "left";
ctx.fillText(label, x - 8, yPos); ctx.textBaseline = "middle";
} ctx.fillText("High", x + width + 5, y + 10);
}); ctx.fillText("Low", x + width + 5, y + height - 10);
};
// "0" at bottom return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
ctx.fillText("0", x - 8, y + height); <canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
</div>);
// 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) {
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
} else if (nyquistFreq <= 96000) {
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
} 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;
return 60;
};
const drawColorBar = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number
) => {
// Draw gradient color bar
for (let i = 0; i < height; i++) {
const intensity = 1 - (i / height); // Top is high, bottom is low
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>
);
} }
+20 -45
View File
@@ -1,55 +1,30 @@
import { X, Minus, Maximize } from "lucide-react"; import { X, Minus, Maximize } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
export function TitleBar() { export function TitleBar() {
const handleMinimize = () => { const handleMinimize = () => {
WindowMinimise(); WindowMinimise();
}; };
const handleMaximize = () => {
const handleMaximize = () => { WindowToggleMaximise();
WindowToggleMaximise(); };
}; const handleClose = () => {
Quit();
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"> <div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
<button <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">
onClick={handleMinimize} <Minus className="w-3.5 h-3.5"/>
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>
<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">
onClick={handleMaximize} <Maximize className="w-3.5 h-3.5"/>
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>
<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">
onClick={handleClose} <X className="w-3.5 h-3.5"/>
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> </button>
</div> </div>
</> </>);
);
} }
+99 -164
View File
@@ -1,204 +1,139 @@
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react"; import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackInfoProps { interface TrackInfoProps {
track: TrackMetadata & { album_name: string; release_date: string }; track: TrackMetadata & {
isDownloading: boolean; album_name: string;
downloadingTrack: string | null; release_date: string;
isDownloaded: boolean; };
isFailed: boolean; isDownloading: boolean;
isSkipped: boolean; downloadingTrack: string | null;
downloadingLyricsTrack?: string | null; isDownloaded: boolean;
downloadedLyrics?: boolean; isFailed: boolean;
failedLyrics?: boolean; isSkipped: boolean;
skippedLyrics?: boolean; downloadingLyricsTrack?: string | null;
checkingAvailability?: boolean; downloadedLyrics?: boolean;
availability?: TrackAvailability; failedLyrics?: boolean;
downloadingCover?: boolean; skippedLyrics?: 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; checkingAvailability?: boolean;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void; availability?: TrackAvailability;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void; downloadingCover?: boolean;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => void; downloadedCover?: boolean;
onOpenFolder: () => void; 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, 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) {
export function TrackInfo({ const formatDuration = (ms: number) => {
track, const minutes = Math.floor(ms / 60000);
isDownloading, const seconds = Math.floor((ms % 60000) / 1000);
downloadingTrack, return `${minutes}:${seconds.toString().padStart(2, "0")}`;
isDownloaded, };
isFailed, const formatPlays = (plays: string) => {
isSkipped, const num = parseInt(plays, 10);
downloadingLyricsTrack, if (isNaN(num))
downloadedLyrics, return plays;
failedLyrics, return num.toLocaleString();
skippedLyrics, };
checkingAvailability, return (<Card>
availability,
downloadingCover,
onDownload,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onOpenFolder,
}: TrackInfoProps) {
const [isHoveringCover, setIsHoveringCover] = useState(false);
return (
<Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
<div <div className="shrink-0">
className="shrink-0 relative" {track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
onMouseEnter={() => setIsHoveringCover(true)} <img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
onMouseLeave={() => setIsHoveringCover(false)} <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)}
{track.images && ( </div>
<> </div>)}
<img
src={track.images}
alt={track.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
{isHoveringCover && onDownloadCover && (
<div className="absolute inset-0 bg-black/50 rounded-md flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="secondary"
className="cursor-pointer"
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name)}
disabled={downloadingCover}
>
{downloadingCover ? <Spinner /> : <ImageDown className="h-5 w-5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>
</div>
)}
</>
)}
</div> </div>
<div className="flex-1 space-y-4 min-w-0"> <div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1> <h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isSkipped ? ( {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}
<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> </div>
<p className="text-lg text-muted-foreground">{track.artists}</p> <p className="text-lg text-muted-foreground">{track.artists}</p>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Album</p> <div>
<p className="font-medium truncate">{track.album_name}</p> <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>
<div> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Release Date</p> <div>
<p className="font-medium">{track.release_date}</p> <p className="text-xs text-muted-foreground">Release Date</p>
<p className="font-medium">{track.release_date}</p>
</div>
{track.copyright && (<div>
<p className="text-xs text-muted-foreground">Copyright</p>
<p className="font-medium truncate" title={track.copyright}>
{track.copyright}
</p>
</div>)}
</div> </div>
</div> </div>
{track.isrc && ( {track.isrc && (<div className="flex gap-2 flex-wrap">
<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}>
<Button {downloadingTrack === track.isrc ? (<Spinner />) : (<>
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)} <Download className="h-4 w-4"/>
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
<Download className="h-4 w-4" />
Download Download
</> </>)}
)}
</Button> </Button>
{track.spotify_id && onDownloadLyrics && ( {track.spotify_id && onDownloadLyrics && (<Tooltip>
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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}>
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)} {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"/>)}
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> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download Lyric</p> <p>Download Lyric</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {track.images && onDownloadCover && (<Tooltip>
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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}>
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} {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"/>)}
variant="outline"
disabled={checkingAvailability}
>
{checkingAvailability ? (
<Spinner />
) : availability ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{availability ? ( <p>Download Cover</p>
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {track.spotify_id && onCheckAvailability && (<Tooltip>
{isDownloaded && ( <TooltipTrigger asChild>
<Button onClick={onOpenFolder} variant="outline"> <Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}>
<FolderOpen className="h-4 w-4" /> {checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder Open Folder
</Button> </Button>)}
)} </div>)}
</div>
)}
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>);
);
} }
+234 -395
View File
@@ -2,486 +2,325 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react"; import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
Tooltip, import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackListProps { interface TrackListProps {
tracks: TrackMetadata[]; tracks: TrackMetadata[];
searchQuery: string; searchQuery: string;
sortBy: string; sortBy: string;
selectedTracks: string[]; selectedTracks: string[];
downloadedTracks: Set<string>; downloadedTracks: Set<string>;
failedTracks: Set<string>; failedTracks: Set<string>;
skippedTracks: Set<string>; skippedTracks: Set<string>;
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloading: boolean; isDownloading: boolean;
currentPage: number; currentPage: number;
itemsPerPage: number; itemsPerPage: number;
showCheckboxes?: boolean; showCheckboxes?: boolean;
hideAlbumColumn?: boolean; hideAlbumColumn?: boolean;
folderName?: string; folderName?: string;
isArtistDiscography?: boolean; isArtistDiscography?: boolean;
// Lyrics props downloadedLyrics?: Set<string>;
downloadedLyrics?: Set<string>; failedLyrics?: Set<string>;
failedLyrics?: Set<string>; skippedLyrics?: Set<string>;
skippedLyrics?: Set<string>; downloadingLyricsTrack?: string | null;
downloadingLyricsTrack?: string | null; checkingAvailabilityTrack?: string | null;
// Availability props availabilityMap?: Map<string, TrackAvailability>;
checkingAvailabilityTrack?: string | null; downloadedCovers?: Set<string>;
availabilityMap?: Map<string, TrackAvailability>; failedCovers?: Set<string>;
// Cover props skippedCovers?: Set<string>;
downloadedCovers?: Set<string>; downloadingCoverTrack?: string | null;
failedCovers?: Set<string>; onToggleTrack: (isrc: string) => void;
skippedCovers?: Set<string>; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
downloadingCoverTrack?: string | null; 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;
onToggleTrack: (isrc: string) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onCheckAvailability?: (spotifyId: string, isrc?: string) => 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; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onPageChange: (page: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void; onAlbumClick?: (album: {
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; id: string;
onPageChange: (page: number) => void; name: string;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void; external_urls: string;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; }) => void;
onTrackClick?: (track: TrackMetadata) => 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({ let filteredTracks = tracks.filter((track) => {
tracks, if (!searchQuery)
searchQuery, return true;
sortBy, const query = searchQuery.toLowerCase();
selectedTracks, return (track.name.toLowerCase().includes(query) ||
downloadedTracks, track.artists.toLowerCase().includes(query) ||
failedTracks, track.album_name.toLowerCase().includes(query));
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) {
let filteredTracks = tracks.filter((track) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
track.name.toLowerCase().includes(query) ||
track.artists.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") {
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
} else if (sortBy === "artist-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
} else if (sortBy === "artist-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
} else if (sortBy === "duration-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
} else if (sortBy === "duration-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
} 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") { if (sortBy === "title-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => { filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
const aDownloaded = downloadedTracks.has(a.isrc); }
const bDownloaded = downloadedTracks.has(b.isrc); else if (sortBy === "title-desc") {
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
}); }
} else if (sortBy === "artist-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage); }
const startIndex = (currentPage - 1) * itemsPerPage; else if (sortBy === "artist-desc") {
const endIndex = startIndex + itemsPerPage; filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
const paginatedTracks = filteredTracks.slice(startIndex, endIndex); }
else if (sortBy === "duration-asc") {
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc); filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
const allSelected = }
tracksWithIsrc.length > 0 && else if (sortBy === "duration-desc") {
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc)); filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
}
const formatDuration = (ms: number) => { else if (sortBy === "plays-asc") {
const minutes = Math.floor(ms / 60000); filteredTracks = [...filteredTracks].sort((a, b) => {
const seconds = Math.floor((ms % 60000) / 1000); const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
return `${minutes}:${seconds.toString().padStart(2, "0")}`; const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
}; if (isNaN(aPlays))
return 1;
return ( if (isNaN(bPlays))
<div className="space-y-4"> 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") {
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 tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
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")}`;
};
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="rounded-md border">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
{showCheckboxes && ( {showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
<th className="h-12 px-4 text-left align-middle w-12"> <Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
<Checkbox </th>)}
checked={allSelected}
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
/>
</th>
)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
# #
</th> </th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title Title
</th> </th>
{!hideAlbumColumn && ( {!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
Album Album
</th> </th>)}
)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
Duration Duration
</th> </th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
Plays
</th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32"> <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{paginatedTracks.map((track, index) => ( {paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
<tr key={index} className="border-b transition-colors hover:bg-muted/50"> {showCheckboxes && (<td className="p-4 align-middle">
{showCheckboxes && ( {track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
<td className="p-4 align-middle"> </td>)}
{track.isrc && (
<Checkbox
checked={selectedTracks.includes(track.isrc)}
onCheckedChange={() => onToggleTrack(track.isrc)}
/>
)}
</td>
)}
<td className="p-4 align-middle text-sm text-muted-foreground"> <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>
<td className="p-4 align-middle"> <td className="p-4 align-middle">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{track.images && ( {track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
<img
src={track.images}
alt={track.name}
className="w-10 h-10 rounded object-cover"
/>
)}
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onTrackClick ? ( {onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
<span
className="font-medium cursor-pointer hover:underline"
onClick={() => onTrackClick(track)}
>
{track.name} {track.name}
</span> </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 className="font-medium">{track.name}</span>
)}
{skippedTracks.has(track.isrc) ? (
<FileCheck className="h-4 w-4 text-yellow-500 shrink-0" />
) : downloadedTracks.has(track.isrc) ? (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
) : failedTracks.has(track.isrc) ? (
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
) : null}
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? ( {track.artists_data && track.artists_data.length > 0 ? ((() => {
track.artists_data.map((artist, i, arr) => ( const artistNames = track.artists.split(", ").map(name => name.trim());
<span key={artist.id}> return artistNames.map((name, i) => {
{onArtistClick ? ( const artistData = track.artists_data![i];
<span const hasArtistData = artistData && artistData.id && artistData.external_urls;
className="cursor-pointer hover:underline" return (<span key={artistData?.id || i}>
onClick={() => {onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
onArtistClick({ id: artistData.id,
id: artist.id, name: name,
name: artist.name, external_urls: artistData.external_urls,
external_urls: artist.external_urls, })}>
}) {name}
} </span>) : (name)}
> {i < artistNames.length - 1 && ", "}
{artist.name} </span>);
</span> });
) : ( })()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
artist.name id: track.artist_id!,
)} name: track.artists,
{i < arr.length - 1 && ", "} external_urls: track.artist_url!,
</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} {track.artists}
</span> </span>) : (track.artists)}
) : (
track.artists
)}
</span> </span>
</div> </div>
</div> </div>
</td> </td>
{!hideAlbumColumn && ( {!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell"> {onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
{onAlbumClick && track.album_id && track.album_url ? ( id: track.album_id!,
<span name: track.album_name,
className="cursor-pointer hover:underline" external_urls: track.album_url!,
onClick={() => })}>
onAlbumClick({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})
}
>
{track.album_name} {track.album_name}
</span> </span>) : (track.album_name)}
) : ( </td>)}
track.album_name
)}
</td>
)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell"> <td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
{formatDuration(track.duration_ms)} {formatDuration(track.duration_ms)}
</td> </td>
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
{track.plays ? formatPlays(track.plays) : ""}
</td>
<td className="p-4 align-middle text-center"> <td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{track.isrc && ( {track.isrc && (<Tooltip>
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="sm" disabled={isDownloading || downloadingTrack === track.isrc}>
onClick={() => {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"/>)}
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> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{downloadingTrack === track.isrc ? ( {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>)}
<p>Downloading...</p>
) : skippedTracks.has(track.isrc) ? (
<p>Already exists</p>
) : downloadedTracks.has(track.isrc) ? (
<p>Downloaded</p>
) : failedTracks.has(track.isrc) ? (
<p>Failed</p>
) : (
<p>Download Track</p>
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {track.spotify_id && onDownloadLyrics && (<Tooltip>
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <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}>
onClick={() => {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"/>)}
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1)
}
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> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download Lyric</p> <p>Download Lyric</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {track.images && onDownloadCover && (<Tooltip>
{track.images && onDownloadCover && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={() => {
onClick={() => { const trackId = track.spotify_id || `${track.name}-${track.artists}`;
const trackId = track.spotify_id || `${track.name}-${track.artists}`; onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId); }} 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="sm"
variant="outline"
disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}
>
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (
<Spinner />
) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<ImageDown className="h-4 w-4" />
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Download Cover</p> <p>Download Cover</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)} {track.spotify_id && onCheckAvailability && (<Tooltip>
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="sm" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} {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"/>)}
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> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{availabilityMap?.has(track.spotify_id) ? ( {availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} /> <QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} /> <AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} /> </div>) : (<p>Check Availability</p>)}
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>)}
)}
</div> </div>
</td> </td>
</tr> </tr>))}
))}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (<Pagination>
<Pagination>
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationPrevious <PaginationPrevious href="#" onClick={(e) => {
href="#" e.preventDefault();
onClick={(e) => { if (currentPage > 1)
e.preventDefault(); onPageChange(currentPage - 1);
if (currentPage > 1) onPageChange(currentPage - 1); }} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
}}
className={
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem> </PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}>
<PaginationItem key={page}> <PaginationLink href="#" onClick={(e) => {
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault(); e.preventDefault();
onPageChange(page); onPageChange(page);
}} }} isActive={currentPage === page} className="cursor-pointer">
isActive={currentPage === page}
className="cursor-pointer"
>
{page} {page}
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>))}
))}
<PaginationItem> <PaginationItem>
<PaginationNext <PaginationNext href="#" onClick={(e) => {
href="#" e.preventDefault();
onClick={(e) => { if (currentPage < totalPages)
e.preventDefault(); onPageChange(currentPage + 1);
if (currentPage < totalPages) onPageChange(currentPage + 1); }} className={currentPage === totalPages
}} ? "pointer-events-none opacity-50"
className={ : "cursor-pointer"}/>
currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem> </PaginationItem>
</PaginationContent> </PaginationContent>
</Pagination> </Pagination>)}
)} </div>);
</div>
);
} }
+39 -80
View File
@@ -1,104 +1,63 @@
'use client'; 'use client';
import type { Variants } from 'motion/react'; import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react'; import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface ActivityIconHandle { export interface ActivityIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> { interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
} }
const PATH_VARIANTS: Variants = { const PATH_VARIANTS: Variants = {
normal: { normal: {
pathLength: 1, pathLength: 1,
opacity: 1, opacity: 1,
pathOffset: 0, pathOffset: 0,
}, },
animate: { animate: {
pathLength: [0, 1], pathLength: [0, 1],
opacity: [0, 1], opacity: [0, 1],
pathOffset: [1, 0], pathOffset: [1, 0],
transition: { transition: {
duration: 0.8, duration: 0.8,
ease: 'easeInOut', ease: 'easeInOut',
},
}, },
},
}; };
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 controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
isControlledRef.current = true; isControlledRef.current = true;
return {
return { startAnimation: () => controls.start('animate'),
startAnimation: () => controls.start('animate'), stopAnimation: () => controls.start('normal'),
stopAnimation: () => controls.start('normal'), };
};
}); });
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('animate'); controls.start('animate');
} else {
onMouseEnter?.(e);
} }
}, else {
[controls, onMouseEnter] onMouseEnter?.(e);
); }
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback( const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('normal'); controls.start('normal');
} else {
onMouseLeave?.(e);
} }
}, else {
[controls, onMouseLeave] onMouseLeave?.(e);
); }
}, [controls, onMouseLeave]);
return ( return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<div <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">
className={cn(className)} <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"/>
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> </svg>
</div> </div>);
); });
}
);
ActivityIcon.displayName = 'ActivityIcon'; ActivityIcon.displayName = 'ActivityIcon';
export { ActivityIcon }; export { ActivityIcon };
+19 -41
View File
@@ -1,46 +1,24 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
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", {
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: { variants: {
variant: { variant: {
default: default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
"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",
secondary: 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",
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
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: { defaultVariants: {
variant: "default", variant: "default",
}, },
} });
) function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
asChild?: boolean;
function Badge({ }) {
className, const Comp = asChild ? Slot : "span";
variant, return (<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props}/>);
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 }
+29 -69
View File
@@ -1,92 +1,52 @@
'use client'; 'use client';
import type { Variants } from 'motion/react'; import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react'; import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface BlocksIconHandle { export interface BlocksIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> { interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
} }
const VARIANTS: Variants = { const VARIANTS: Variants = {
normal: { translateX: 0, translateY: 0 }, normal: { translateX: 0, translateY: 0 },
animate: { translateX: -4, translateY: 4 }, 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 controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
isControlledRef.current = true; isControlledRef.current = true;
return {
return { startAnimation: () => controls.start('animate'),
startAnimation: () => controls.start('animate'), stopAnimation: () => controls.start('normal'),
stopAnimation: () => controls.start('normal'), };
};
}); });
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('animate'); controls.start('animate');
} else {
onMouseEnter?.(e);
} }
}, else {
[controls, onMouseEnter] onMouseEnter?.(e);
); }
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback( const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('normal'); controls.start('normal');
} else {
onMouseLeave?.(e);
} }
}, else {
[controls, onMouseLeave] onMouseLeave?.(e);
); }
}, [controls, onMouseLeave]);
return ( return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<div <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">
className={cn(className)} <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"/>
onMouseEnter={handleMouseEnter} <motion.path d="M14 3h7v7h-7z" variants={VARIANTS} animate={controls}/>
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}
/>
</svg> </svg>
</div> </div>);
); });
}
);
BlocksIcon.displayName = 'BlocksIcon'; BlocksIcon.displayName = 'BlocksIcon';
export { BlocksIcon }; export { BlocksIcon };
+30 -55
View File
@@ -1,60 +1,35 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
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", {
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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"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",
outline: secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
secondary: link: "text-primary underline-offset-4 hover:underline",
"bg-secondary text-secondary-foreground hover:bg-secondary/80", },
ghost: size: {
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", default: "h-9 px-4 py-2 has-[>svg]:px-3",
link: "text-primary underline-offset-4 hover:underline", 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",
size: { icon: "size-9",
default: "h-9 px-4 py-2 has-[>svg]:px-3", "icon-sm": "size-8",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", "icon-lg": "size-10",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", },
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} });
) function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
asChild?: boolean;
function Button({ }) {
className, const Comp = asChild ? Slot : "button";
variant, return (<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props}/>);
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}
/>
)
} }
export { Button, buttonVariants };
export { Button, buttonVariants }
+10 -78
View File
@@ -1,92 +1,24 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( 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}/>);
<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">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( 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}/>);
<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">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (<div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props}/>);
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props}/>);
<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">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (<div data-slot="card-content" className={cn("px-6", className)} {...props}/>);
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props}/>);
<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,
} }
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, };
+11 -30
View File
@@ -1,32 +1,13 @@
"use client" "use client";
import * as React from "react";
import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import { CheckIcon } from "lucide-react";
import { CheckIcon } from "lucide-react" import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
import { cn } from "@/lib/utils" 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">
function Checkbox({ <CheckIcon className="size-3.5"/>
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.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>);
)
} }
export { Checkbox };
export { Checkbox }
+42 -94
View File
@@ -1,118 +1,66 @@
'use client'; 'use client';
import type { Variants } from 'motion/react'; import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react'; import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface CoffeeIconHandle { export interface CoffeeIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> { interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
} }
const PATH_VARIANTS: Variants = { const PATH_VARIANTS: Variants = {
normal: { normal: {
y: 0, y: 0,
opacity: 1, opacity: 1,
},
animate: (custom: number) => ({
y: -3,
opacity: [0, 1, 0],
transition: {
repeat: Infinity,
duration: 1.5,
ease: 'easeInOut',
delay: 0.2 * custom,
}, },
}), animate: (custom: number) => ({
y: -3,
opacity: [0, 1, 0],
transition: {
repeat: Infinity,
duration: 1.5,
ease: 'easeInOut',
delay: 0.2 * custom,
},
}),
}; };
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 controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
isControlledRef.current = true; isControlledRef.current = true;
return {
return { startAnimation: () => controls.start('animate'),
startAnimation: () => controls.start('animate'), stopAnimation: () => controls.start('normal'),
stopAnimation: () => controls.start('normal'), };
};
}); });
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('animate'); controls.start('animate');
} else {
onMouseEnter?.(e);
} }
}, else {
[controls, onMouseEnter] onMouseEnter?.(e);
); }
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback( const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('normal'); controls.start('normal');
} else {
onMouseLeave?.(e);
} }
}, else {
[controls, onMouseLeave] onMouseLeave?.(e);
); }
}, [controls, onMouseLeave]);
return ( return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<div <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' }}>
className={cn(className)} <motion.path d="M10 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.2}/>
onMouseEnter={handleMouseEnter} <motion.path d="M14 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.4}/>
onMouseLeave={handleMouseLeave} <motion.path d="M6 2v2" animate={controls} variants={PATH_VARIANTS} custom={0}/>
{...props} <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
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> </svg>
</div> </div>);
); });
}
);
CoffeeIcon.displayName = 'CoffeeIcon'; CoffeeIcon.displayName = 'CoffeeIcon';
export { CoffeeIcon }; export { CoffeeIcon };
+48 -223
View File
@@ -1,252 +1,77 @@
"use client" "use client";
import * as React from "react";
import * as React from "react" import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
import { cn } from "@/lib/utils" return <ContextMenuPrimitive.Root data-slot="context-menu" {...props}/>;
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
} }
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
function ContextMenuTrigger({ return (<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props}/>);
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
} }
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
function ContextMenuGroup({ return (<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props}/>);
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
} }
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
function ContextMenuPortal({ return (<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props}/>);
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
} }
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
function ContextMenuSub({ return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props}/>;
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
} }
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
function ContextMenuRadioGroup({ return (<ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props}/>);
...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> & {
function ContextMenuSubTrigger({ inset?: boolean;
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) { }) {
return ( 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}>
<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} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto"/>
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>);
)
} }
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
function ContextMenuSubContent({ 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}/>);
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>) {
function ContextMenuContent({ return (<ContextMenuPrimitive.Portal>
className, <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}/>
...props </ContextMenuPrimitive.Portal>);
}: 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> & {
function ContextMenuItem({ inset?: boolean;
className, variant?: "default" | "destructive";
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) { }) {
return ( 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}/>);
<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>) {
function ContextMenuCheckboxItem({ 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}>
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"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4"/>
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</ContextMenuPrimitive.CheckboxItem> </ContextMenuPrimitive.CheckboxItem>);
)
} }
function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
function ContextMenuRadioItem({ 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}>
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"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" /> <CircleIcon className="size-2 fill-current"/>
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</ContextMenuPrimitive.RadioItem> </ContextMenuPrimitive.RadioItem>);
)
} }
function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
function ContextMenuLabel({ inset?: boolean;
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) { }) {
return ( 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}/>);
<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>) {
function ContextMenuSeparator({ return (<ContextMenuPrimitive.Separator data-slot="context-menu-separator" className={cn("bg-border -mx-1 my-1 h-px", className)} {...props}/>);
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">) {
function ContextMenuShortcut({ return (<span data-slot="context-menu-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props}/>);
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,
} }
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" "use client";
import * as React from "react";
import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog" import { XIcon } from "lucide-react";
import { XIcon } from "lucide-react" import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
import { cn } from "@/lib/utils" return <DialogPrimitive.Root data-slot="dialog" {...props}/>;
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}/>;
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
} }
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props}/>;
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({ return <DialogPrimitive.Close data-slot="dialog-close" {...props}/>;
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
function DialogOverlay({ 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}/>);
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> & {
function DialogContent({ showCloseButton?: boolean;
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) { }) {
return ( return (<DialogPortal data-slot="dialog-portal">
<DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <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}>
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} {children}
{showCloseButton && ( {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">
<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 /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>)}
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>);
)
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (<div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props}/>);
<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">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (<div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props}/>);
<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>) {
function DialogTitle({ return (<DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props}/>);
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>) {
function DialogDescription({ return (<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
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,
} }
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 };
+71 -118
View File
@@ -1,149 +1,102 @@
'use client'; 'use client';
import type { Variants } from 'motion/react'; import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react'; import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface GithubIconHandle { export interface GithubIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> { interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
} }
const BODY_VARIANTS: Variants = { const BODY_VARIANTS: Variants = {
normal: { normal: {
opacity: 1, opacity: 1,
pathLength: 1, pathLength: 1,
scale: 1, scale: 1,
transition: { transition: {
duration: 0.3, duration: 0.3,
},
}, },
}, animate: {
animate: { opacity: [0, 1],
opacity: [0, 1], pathLength: [0, 1],
pathLength: [0, 1], scale: [0.9, 1],
scale: [0.9, 1], transition: {
transition: { duration: 0.4,
duration: 0.4, },
}, },
},
}; };
const TAIL_VARIANTS: Variants = { const TAIL_VARIANTS: Variants = {
normal: { normal: {
pathLength: 1, pathLength: 1,
rotate: 0, rotate: 0,
transition: { transition: {
duration: 0.3, duration: 0.3,
},
}, },
}, draw: {
draw: { pathLength: [0, 1],
pathLength: [0, 1], rotate: 0,
rotate: 0, transition: {
transition: { duration: 0.5,
duration: 0.5, },
}, },
}, wag: {
wag: { pathLength: 1,
pathLength: 1, rotate: [0, -15, 15, -10, 10, -5, 5],
rotate: [0, -15, 15, -10, 10, -5, 5], transition: {
transition: { duration: 2.5,
duration: 2.5, ease: 'easeInOut',
ease: 'easeInOut', repeat: Infinity,
repeat: Infinity, },
}, },
},
}; };
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 bodyControls = useAnimation();
const tailControls = useAnimation(); const tailControls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
isControlledRef.current = true; isControlledRef.current = true;
return {
return { startAnimation: async () => {
startAnimation: async () => { bodyControls.start('animate');
bodyControls.start('animate'); await tailControls.start('draw');
await tailControls.start('draw'); tailControls.start('wag');
tailControls.start('wag'); },
}, stopAnimation: () => {
stopAnimation: () => { bodyControls.start('normal');
bodyControls.start('normal'); tailControls.start('normal');
tailControls.start('normal'); },
}, };
};
}); });
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(
async (e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
bodyControls.start('animate'); bodyControls.start('animate');
await tailControls.start('draw'); await tailControls.start('draw');
tailControls.start('wag'); tailControls.start('wag');
} else {
onMouseEnter?.(e);
} }
}, else {
[bodyControls, onMouseEnter, tailControls] onMouseEnter?.(e);
); }
}, [bodyControls, onMouseEnter, tailControls]);
const handleMouseLeave = useCallback( const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
bodyControls.start('normal'); bodyControls.start('normal');
tailControls.start('normal'); tailControls.start('normal');
} else {
onMouseLeave?.(e);
} }
}, else {
[bodyControls, tailControls, onMouseLeave] onMouseLeave?.(e);
); }
}, [bodyControls, tailControls, onMouseLeave]);
return ( return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<div <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">
className={cn(className)} <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"/>
onMouseEnter={handleMouseEnter} <motion.path variants={TAIL_VARIANTS} initial="normal" animate={tailControls} d="M9 18c-4.51 2-5-2-7-2"/>
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> </svg>
</div> </div>);
); });
}
);
GithubIcon.displayName = 'GithubIcon'; GithubIcon.displayName = 'GithubIcon';
export { GithubIcon }; export { GithubIcon };
+37 -78
View File
@@ -1,103 +1,62 @@
'use client'; 'use client';
import type { Transition, Variants } from 'motion/react'; import type { Transition, Variants } from 'motion/react';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react'; import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface HomeIconHandle { export interface HomeIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> { interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
} }
const DEFAULT_TRANSITION: Transition = { const DEFAULT_TRANSITION: Transition = {
duration: 0.6, duration: 0.6,
opacity: { duration: 0.2 }, opacity: { duration: 0.2 },
}; };
const PATH_VARIANTS: Variants = { const PATH_VARIANTS: Variants = {
normal: { normal: {
pathLength: 1, pathLength: 1,
opacity: 1, opacity: 1,
}, },
animate: { animate: {
opacity: [0, 1], opacity: [0, 1],
pathLength: [0, 1], 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 controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
isControlledRef.current = true; isControlledRef.current = true;
return {
return { startAnimation: () => controls.start('animate'),
startAnimation: () => controls.start('animate'), stopAnimation: () => controls.start('normal'),
stopAnimation: () => controls.start('normal'), };
};
}); });
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('animate'); controls.start('animate');
} else {
onMouseEnter?.(e);
} }
}, else {
[controls, onMouseEnter] onMouseEnter?.(e);
); }
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback( const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('normal'); controls.start('normal');
} else {
onMouseLeave?.(e);
} }
}, else {
[controls, onMouseLeave] onMouseLeave?.(e);
); }
return ( }, [controls, onMouseLeave]);
<div return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
className={cn(className)} <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">
onMouseEnter={handleMouseEnter} <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"/>
onMouseLeave={handleMouseLeave} <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}/>
{...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}
/>
</svg> </svg>
</div> </div>);
); });
}
);
HomeIcon.displayName = 'HomeIcon'; HomeIcon.displayName = 'HomeIcon';
export { HomeIcon }; export { HomeIcon };
+121 -181
View File
@@ -1,216 +1,156 @@
import * as React from "react"; import * as React from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu";
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Scissors, Copy, Clipboard, Type } from "lucide-react"; import { Scissors, Copy, Clipboard, Type } from "lucide-react";
export interface InputWithContextProps extends React.InputHTMLAttributes<HTMLInputElement> {
export interface InputWithContextProps onValueChange?: (value: string) => void;
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 inputRef = React.useRef<HTMLInputElement>(null);
const [hasSelection, setHasSelection] = React.useState(false); const [hasSelection, setHasSelection] = React.useState(false);
const [canPaste, setCanPaste] = React.useState(false); const [canPaste, setCanPaste] = React.useState(false);
// Combine refs
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
// Check selection state
const updateSelectionState = () => { const updateSelectionState = () => {
const input = inputRef.current; const input = inputRef.current;
if (!input) return; if (!input)
const start = input.selectionStart ?? 0; return;
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 {
setCanPaste(false);
}
};
const handleCut = async () => {
const input = inputRef.current;
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,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
} catch (err) {
console.error("Failed to cut:", err);
}
}
};
const handleCopy = async () => {
const input = inputRef.current;
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) {
console.error("Failed to copy:", err);
}
}
};
const handlePaste = async () => {
const input = inputRef.current;
if (!input) return;
try {
const text = await navigator.clipboard.readText();
const start = input.selectionStart ?? 0; const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0; const end = input.selectionEnd ?? 0;
setHasSelection(start !== end);
const newValue = };
input.value.substring(0, start) + text + input.value.substring(end); const checkClipboard = async () => {
try {
// Update value and trigger change const text = await navigator.clipboard.readText();
input.value = newValue; setCanPaste(text.length > 0);
const newPosition = start + text.length; }
input.setSelectionRange(newPosition, newPosition); catch {
setCanPaste(false);
// Trigger React onChange }
if (onChange) { };
const event = { const handleCut = async () => {
target: input, const input = inputRef.current;
currentTarget: input, if (!input)
} as React.ChangeEvent<HTMLInputElement>; return;
onChange(event); const start = input.selectionStart ?? 0;
} const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (onValueChange) { if (selectedText) {
onValueChange(newValue); try {
} await navigator.clipboard.writeText(selectedText);
const newValue = input.value.substring(0, start) + input.value.substring(end);
input.focus(); input.value = newValue;
await checkClipboard(); input.setSelectionRange(start, start);
} catch (err) { if (onChange) {
console.error("Failed to paste:", err); const event = {
} target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
}
catch (err) {
console.error("Failed to cut:", err);
}
}
};
const handleCopy = async () => {
const input = inputRef.current;
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) {
console.error("Failed to copy:", err);
}
}
};
const handlePaste = async () => {
const input = inputRef.current;
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);
input.value = newValue;
const newPosition = start + text.length;
input.setSelectionRange(newPosition, newPosition);
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
await checkClipboard();
}
catch (err) {
console.error("Failed to paste:", err);
}
}; };
const handleSelectAll = () => { const handleSelectAll = () => {
const input = inputRef.current; const input = inputRef.current;
if (!input) return; if (!input)
input.select(); return;
input.focus(); input.select();
updateSelectionState(); input.focus();
updateSelectionState();
}; };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) { if (onChange) {
onChange(e); onChange(e);
} }
if (onValueChange) { if (onValueChange) {
onValueChange(e.target.value); onValueChange(e.target.value);
} }
}; };
return (<ContextMenu onOpenChange={(open) => {
return ( if (open) {
<ContextMenu checkClipboard();
onOpenChange={(open) => { }
if (open) { }}>
checkClipboard();
}
}}
>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<Input <Input ref={inputRef} type={type} className={className} onChange={handleInputChange} onSelect={updateSelectionState} onMouseUp={updateSelectionState} onKeyUp={updateSelectionState} {...props}/>
ref={inputRef}
type={type}
className={className}
onChange={handleInputChange}
onSelect={updateSelectionState}
onMouseUp={updateSelectionState}
onKeyUp={updateSelectionState}
{...props}
/>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent className="w-48"> <ContextMenuContent className="w-48">
<ContextMenuItem <ContextMenuItem onSelect={handleCut} disabled={!hasSelection || props.disabled || props.readOnly}>
onSelect={handleCut} <Scissors className="mr-2 h-4 w-4"/>
disabled={!hasSelection || props.disabled || props.readOnly}
>
<Scissors className="mr-2 h-4 w-4" />
Cut Cut
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span> <span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem onSelect={handleCopy} disabled={!hasSelection || props.disabled}>
onSelect={handleCopy} <Copy className="mr-2 h-4 w-4"/>
disabled={!hasSelection || props.disabled}
>
<Copy className="mr-2 h-4 w-4" />
Copy Copy
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span> <span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem <ContextMenuItem onSelect={handlePaste} disabled={!canPaste || props.disabled || props.readOnly}>
onSelect={handlePaste} <Clipboard className="mr-2 h-4 w-4"/>
disabled={!canPaste || props.disabled || props.readOnly}
>
<Clipboard className="mr-2 h-4 w-4" />
Paste Paste
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span> <span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem onSelect={handleSelectAll} disabled={!inputRef.current?.value || props.disabled}>
onSelect={handleSelectAll} <Type className="mr-2 h-4 w-4"/>
disabled={!inputRef.current?.value || props.disabled}
>
<Type className="mr-2 h-4 w-4" />
Select All Select All
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span> <span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>);
); });
}
);
InputWithContext.displayName = "InputWithContext"; InputWithContext.displayName = "InputWithContext";
export { InputWithContext }; export { InputWithContext };
+4 -19
View File
@@ -1,21 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( 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}/>);
<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" "use client";
import * as React from "react";
import * as React from "react" import * as LabelPrimitive from "@radix-ui/react-label";
import * as LabelPrimitive from "@radix-ui/react-label" import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
import { cn } from "@/lib/utils" 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}/>);
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 }
+26 -112
View File
@@ -1,127 +1,41 @@
import * as React from "react" import * as React from "react";
import { import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react";
ChevronLeftIcon, import { cn } from "@/lib/utils";
ChevronRightIcon, import { Button, buttonVariants } from "@/components/ui/button";
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) { function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return ( return (<nav role="navigation" aria-label="pagination" data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...props}/>);
<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">) {
function PaginationContent({ return (<ul data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)} {...props}/>);
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">) { function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} /> return <li data-slot="pagination-item" {...props}/>;
} }
type PaginationLinkProps = { type PaginationLinkProps = {
isActive?: boolean isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> & } & Pick<React.ComponentProps<typeof Button>, "size"> & React.ComponentProps<"a">;
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({
function PaginationLink({ variant: isActive ? "outline" : "ghost",
className, size,
isActive, }), className)} {...props}/>);
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}
/>
)
} }
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
function PaginationPrevious({ return (<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 px-2.5 sm:pl-2.5", className)} {...props}>
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 /> <ChevronLeftIcon />
<span className="hidden sm:block">Previous</span> <span className="hidden sm:block">Previous</span>
</PaginationLink> </PaginationLink>);
)
} }
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
function PaginationNext({ return (<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 px-2.5 sm:pr-2.5", className)} {...props}>
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> <span className="hidden sm:block">Next</span>
<ChevronRightIcon /> <ChevronRightIcon />
</PaginationLink> </PaginationLink>);
)
} }
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
function PaginationEllipsis({ return (<span aria-hidden data-slot="pagination-ellipsis" className={cn("flex size-9 items-center justify-center", className)} {...props}>
className, <MoreHorizontalIcon className="size-4"/>
...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 className="sr-only">More pages</span>
</span> </span>);
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
} }
export { Pagination, PaginationContent, PaginationLink, PaginationItem, PaginationPrevious, PaginationNext, PaginationEllipsis, };
+9 -30
View File
@@ -1,31 +1,10 @@
"use client" "use client";
import * as React from "react";
import * as React from "react" import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as ProgressPrimitive from "@radix-ui/react-progress" import { cn } from "@/lib/utils";
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
import { cn } from "@/lib/utils" 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)}%)` }}/>
function Progress({ </ProgressPrimitive.Root>);
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 }
+39 -161
View File
@@ -1,185 +1,63 @@
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils" function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props}/>;
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
} }
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({ return <SelectPrimitive.Group data-slot="select-group" {...props}/>;
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
} }
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
function SelectValue({ return <SelectPrimitive.Value data-slot="select-value" {...props}/>;
...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> & {
function SelectTrigger({ size?: "sm" | "default";
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) { }) {
return ( 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}>
<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} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50"/>
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>);
)
} }
function SelectContent({ className, children, position = "popper", align = "center", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
function SelectContent({ return (<SelectPrimitive.Portal>
className, <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" &&
children, "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}>
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 /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport className={cn("p-1", position === "popper" &&
className={cn( "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1")}>
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
)}
>
{children} {children}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>);
)
} }
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({ return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
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>) {
function SelectItem({ 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}>
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"> <span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>);
)
} }
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
function SelectSeparator({ return (<SelectPrimitive.Separator data-slot="select-separator" className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props}/>);
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>) {
function SelectScrollUpButton({ return (<SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
className, <ChevronUpIcon className="size-4"/>
...props </SelectPrimitive.ScrollUpButton>);
}: 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>
)
} }
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
function SelectScrollDownButton({ return (<SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
className, <ChevronDownIcon className="size-4"/>
...props </SelectPrimitive.ScrollDownButton>);
}: 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,
} }
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, };
+30 -68
View File
@@ -1,92 +1,54 @@
'use client'; 'use client';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react'; import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface SettingsIconHandle { export interface SettingsIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> { interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; 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 controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
isControlledRef.current = true; isControlledRef.current = true;
return {
return { startAnimation: () => controls.start('animate'),
startAnimation: () => controls.start('animate'), stopAnimation: () => controls.start('normal'),
stopAnimation: () => controls.start('normal'), };
};
}); });
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('animate'); controls.start('animate');
} else {
onMouseEnter?.(e);
} }
}, else {
[controls, onMouseEnter] onMouseEnter?.(e);
); }
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback( const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('normal'); controls.start('normal');
} else {
onMouseLeave?.(e);
} }
}, else {
[controls, onMouseLeave] onMouseLeave?.(e);
); }
}, [controls, onMouseLeave]);
return ( return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<div <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={{
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: { normal: {
rotate: 0, rotate: 0,
}, },
animate: { animate: {
rotate: 180, 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"/>
<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> </motion.svg>
</div> </div>);
); });
}
);
SettingsIcon.displayName = 'SettingsIcon'; SettingsIcon.displayName = 'SettingsIcon';
export { SettingsIcon }; export { SettingsIcon };
+26 -46
View File
@@ -1,47 +1,27 @@
import { import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, } from "lucide-react";
CircleCheckIcon, import { useTheme } from "next-themes";
InfoIcon, import { Toaster as Sonner, type ToasterProps } from "sonner";
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return (<Sonner theme={theme as ToasterProps["theme"]} className="toaster group" icons={{
return ( success: <CircleCheckIcon className="size-4"/>,
<Sonner info: <InfoIcon className="size-4"/>,
theme={theme as ToasterProps["theme"]} warning: <TriangleAlertIcon className="size-4"/>,
className="toaster group" error: <OctagonXIcon className="size-4"/>,
icons={{ loading: <Loader2Icon className="size-4 animate-spin"/>,
success: <CircleCheckIcon className="size-4" />, }} toastOptions={{
info: <InfoIcon className="size-4" />, classNames: {
warning: <TriangleAlertIcon className="size-4" />, success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
error: <OctagonXIcon className="size-4" />, error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
loading: <Loader2Icon className="size-4 animate-spin" />, 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",
toastOptions={{ },
classNames: { }} style={{
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500", "--normal-bg": "var(--popover)",
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500", "--normal-text": "var(--popover-foreground)",
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500", "--normal-border": "var(--border)",
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500", "--border-radius": "var(--radius)",
}, left: "calc(56px + 1rem)",
}} } as React.CSSProperties} {...props}/>);
style={ };
{ export { Toaster };
"--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 }
+4 -13
View File
@@ -1,15 +1,6 @@
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) { function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return ( return (<Loader2 role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props}/>);
<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" "use client";
import * as React from "react";
import * as React from "react" import * as SwitchPrimitive from "@radix-ui/react-switch";
import * as SwitchPrimitive from "@radix-ui/react-switch" import { cn } from "@/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
import { cn } from "@/lib/utils" 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")}/>
function Switch({ </SwitchPrimitive.Root>);
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 }
+35 -79
View File
@@ -1,103 +1,59 @@
'use client'; 'use client';
import type { Variants } from 'motion/react'; import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react'; import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface TerminalIconHandle { export interface TerminalIconHandle {
startAnimation: () => void; startAnimation: () => void;
stopAnimation: () => void; stopAnimation: () => void;
} }
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> { interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
} }
const LINE_VARIANTS: Variants = { const LINE_VARIANTS: Variants = {
normal: { opacity: 1 }, normal: { opacity: 1 },
animate: { animate: {
opacity: [1, 0, 1], opacity: [1, 0, 1],
transition: { transition: {
duration: 0.8, duration: 0.8,
repeat: Infinity, repeat: Infinity,
ease: 'linear', ease: 'linear',
},
}, },
},
}; };
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 controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
isControlledRef.current = true; isControlledRef.current = true;
return {
return { startAnimation: () => controls.start('animate'),
startAnimation: () => controls.start('animate'), stopAnimation: () => controls.start('normal'),
stopAnimation: () => controls.start('normal'), };
};
}); });
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('animate'); controls.start('animate');
} else {
onMouseEnter?.(e);
} }
}, else {
[controls, onMouseEnter] onMouseEnter?.(e);
); }
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback( const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) { if (!isControlledRef.current) {
controls.start('normal'); controls.start('normal');
} else {
onMouseLeave?.(e);
} }
}, else {
[controls, onMouseLeave] onMouseLeave?.(e);
); }
}, [controls, onMouseLeave]);
return ( return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<div <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">
className={cn(className)} <polyline points="4 17 10 11 4 5"/>
onMouseEnter={handleMouseEnter} <motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={controls} initial="normal"/>
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"
/>
</svg> </svg>
</div> </div>);
); });
}
);
TerminalIcon.displayName = 'TerminalIcon'; TerminalIcon.displayName = 'TerminalIcon';
export { TerminalIcon }; export { TerminalIcon };
+26 -77
View File
@@ -1,83 +1,32 @@
"use client" "use client";
import * as React from "react";
import * as React from "react" import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" import { type VariantProps } from "class-variance-authority";
import { type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
import { cn } from "@/lib/utils" const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants> & {
import { toggleVariants } from "@/components/ui/toggle" spacing?: number;
}>({
const ToggleGroupContext = React.createContext< size: "default",
VariantProps<typeof toggleVariants> & { variant: "default",
spacing?: number spacing: 0,
} });
>({ function ToggleGroup({ className, variant, size, spacing = 0, children, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants> & {
size: "default", spacing?: number;
variant: "default", }) {
spacing: 0, 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}>
})
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}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}> <ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children} {children}
</ToggleGroupContext.Provider> </ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root> </ToggleGroupPrimitive.Root>);
)
} }
function ToggleGroupItem({ className, children, variant, size, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
function ToggleGroupItem({ const context = React.useContext(ToggleGroupContext);
className, 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({
children, variant: context.variant || variant,
variant, size: context.size || 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}>
...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}
>
{children} {children}
</ToggleGroupPrimitive.Item> </ToggleGroupPrimitive.Item>);
)
} }
export { ToggleGroup, ToggleGroupItem };
export { ToggleGroup, ToggleGroupItem }
+21 -42
View File
@@ -1,47 +1,26 @@
"use client" "use client";
import * as React from "react";
import * as React from "react" import * as TogglePrimitive from "@radix-ui/react-toggle";
import * as TogglePrimitive from "@radix-ui/react-toggle" import { cva, type VariantProps } from "class-variance-authority";
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", {
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: { variants: {
variant: { variant: {
default: "bg-transparent", default: "bg-transparent",
outline: outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", },
}, size: {
size: { default: "h-9 px-2 min-w-9",
default: "h-9 px-2 min-w-9", sm: "h-8 px-1.5 min-w-8",
sm: "h-8 px-1.5 min-w-8", lg: "h-10 px-2.5 min-w-10",
lg: "h-10 px-2.5 min-w-10", },
},
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "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 }
+18 -55
View File
@@ -1,61 +1,24 @@
"use client" "use client";
import * as React from "react";
import * as React from "react" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import { cn } from "@/lib/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
import { cn } from "@/lib/utils" return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props}/>);
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>) {
function Tooltip({ return (<TooltipProvider>
...props <TooltipPrimitive.Root data-slot="tooltip" {...props}/>
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { </TooltipProvider>);
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
} }
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
function TooltipTrigger({ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props}/>;
...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>) {
function TooltipContent({ return (<TooltipPrimitive.Portal>
className, <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}>
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} {children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]"/>
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>);
)
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+134 -159
View File
@@ -4,169 +4,144 @@ import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache"; import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
const STORAGE_KEY = "spotiflac_audio_analysis_state"; const STORAGE_KEY = "spotiflac_audio_analysis_state";
export function useAudioAnalysis() { export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false); const [analyzing, setAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(() => { const [result, setResult] = useState<AnalysisResult | null>(() => {
// Load from sessionStorage on mount - only detail, no spectrum try {
try { const saved = sessionStorage.getItem(STORAGE_KEY);
const saved = sessionStorage.getItem(STORAGE_KEY); if (saved) {
if (saved) { const parsed = JSON.parse(saved);
const parsed = JSON.parse(saved); if (parsed.filePath && parsed.result) {
if (parsed.filePath && parsed.result) { return {
// Return result WITHOUT spectrum - spectrum will be loaded async ...parsed.result,
return { spectrum: undefined,
...parsed.result, };
spectrum: undefined, }
}; }
} }
} catch (err) {
} catch (err) { console.error("Failed to load saved analysis state:", 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
}
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;
} }
} return null;
} catch (err) {
// Ignore
}
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) {
console.error("Failed to save analysis state:", err);
}
setResult(analysisResult);
setSpectrumLoading(false); // Spectrum is now available
return analysisResult;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`);
setError(errorMessage);
toast.error("Audio Analysis Failed", {
description: errorMessage,
});
return null;
} finally {
setAnalyzing(false);
}
}, []);
const clearResult = useCallback(() => {
setResult(null);
setError(null);
setSelectedFilePath("");
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch (err) {
// Ignore
}
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(() => {
const cachedSpectrum = getSpectrumCache(selectedFilePath);
if (cachedSpectrum) {
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
setSpectrumLoading(false);
} else {
// Spectrum not in cache - user needs to re-analyze
setSpectrumLoading(false);
}
});
};
// Double RAF to ensure detail is fully rendered
requestAnimationFrame(() => {
requestAnimationFrame(loadSpectrum);
}); });
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
return () => { try {
if (rafId) { const saved = sessionStorage.getItem(STORAGE_KEY);
cancelAnimationFrame(rafId); if (saved) {
} const parsed = JSON.parse(saved);
return parsed.filePath || "";
}
}
catch (err) {
}
return "";
});
const [error, setError] = useState<string | null>(null);
const [spectrumLoading, setSpectrumLoading] = useState(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.filePath && parsed.result) {
return true;
}
}
}
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`);
if (analysisResult.spectrum) {
setSpectrumCache(filePath, analysisResult.spectrum);
}
const { spectrum, ...detailResult } = analysisResult;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
filePath,
result: detailResult,
}));
}
catch (err) {
console.error("Failed to save analysis state:", err);
}
setResult(analysisResult);
setSpectrumLoading(false);
return analysisResult;
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`);
setError(errorMessage);
toast.error("Audio Analysis Failed", {
description: errorMessage,
});
return null;
}
finally {
setAnalyzing(false);
}
}, []);
const clearResult = useCallback(() => {
setResult(null);
setError(null);
setSelectedFilePath("");
try {
sessionStorage.removeItem(STORAGE_KEY);
}
catch (err) {
}
clearSpectrumCache();
}, []);
useEffect(() => {
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
return;
}
let rafId: number;
const loadSpectrum = () => {
rafId = requestAnimationFrame(() => {
const cachedSpectrum = getSpectrumCache(selectedFilePath);
if (cachedSpectrum) {
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
setSpectrumLoading(false);
}
else {
setSpectrumLoading(false);
}
});
};
requestAnimationFrame(() => {
requestAnimationFrame(loadSpectrum);
});
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [result, selectedFilePath, spectrumLoading]);
return {
analyzing,
result,
error,
selectedFilePath,
spectrumLoading,
analyzeFile,
clearResult,
}; };
}, [result, selectedFilePath, spectrumLoading]);
return {
analyzing,
result,
error,
selectedFilePath,
spectrumLoading,
analyzeFile,
clearResult,
};
} }
+54 -63
View File
@@ -2,68 +2,59 @@ import { useState, useCallback } from "react";
import { CheckTrackAvailability } from "../../wailsjs/go/main/App"; import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
import type { TrackAvailability } from "@/types/api"; import type { TrackAvailability } from "@/types/api";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
export function useAvailability() { export function useAvailability() {
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null); const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map()); const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => { if (!spotifyId) {
if (!spotifyId) { setError("No Spotify ID provided");
setError("No Spotify ID provided"); return null;
return null; }
} if (availabilityMap.has(spotifyId)) {
return availabilityMap.get(spotifyId)!;
// Check if already cached }
if (availabilityMap.has(spotifyId)) { setChecking(true);
return availabilityMap.get(spotifyId)!; setCheckingTrackId(spotifyId);
} setError(null);
try {
setChecking(true); logger.info(`Checking availability for track: ${spotifyId}`);
setCheckingTrackId(spotifyId); const response = await CheckTrackAvailability(spotifyId, isrc || "");
setError(null); const availability: TrackAvailability = JSON.parse(response);
setAvailabilityMap((prev) => {
try { const newMap = new Map(prev);
logger.info(`Checking availability for track: ${spotifyId}`); newMap.set(spotifyId, availability);
const response = await CheckTrackAvailability(spotifyId, isrc || ""); return newMap;
const availability: TrackAvailability = JSON.parse(response); });
logger.success(`Availability check completed for ${spotifyId}`);
setAvailabilityMap((prev) => { return availability;
const newMap = new Map(prev); }
newMap.set(spotifyId, availability); catch (err) {
return newMap; const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
}); logger.error(`Availability check error: ${errorMessage}`);
setError(errorMessage);
logger.success(`Availability check completed for ${spotifyId}`); return null;
return availability; }
} catch (err) { finally {
const errorMessage = err instanceof Error ? err.message : "Failed to check availability"; setChecking(false);
logger.error(`Availability check error: ${errorMessage}`); setCheckingTrackId(null);
setError(errorMessage); }
return null; }, [availabilityMap]);
} finally { const getAvailability = useCallback((spotifyId: string) => {
setChecking(false); return availabilityMap.get(spotifyId);
setCheckingTrackId(null); }, [availabilityMap]);
} const clearAvailability = useCallback(() => {
}, [availabilityMap]); setAvailabilityMap(new Map());
setError(null);
const getAvailability = useCallback((spotifyId: string) => { }, []);
return availabilityMap.get(spotifyId); return {
}, [availabilityMap]); checking,
checkingTrackId,
const clearAvailability = useCallback(() => { availabilityMap,
setAvailabilityMap(new Map()); error,
setError(null); checkAvailability,
}, []); getAvailability,
clearAvailability,
return { };
checking,
checkingTrackId,
availabilityMap,
error,
checkAvailability,
getAvailability,
clearAvailability,
};
} }
+195 -225
View File
@@ -5,234 +5,204 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils"; import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
export function useCover() { export function useCover() {
const [downloadingCover, setDownloadingCover] = useState(false); const [downloadingCover, setDownloadingCover] = useState(false);
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null); const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set()); const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set());
const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set()); const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set());
const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set()); const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set());
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false); const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0); const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
const stopBulkDownloadRef = useRef(false); 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, isAlbum?: boolean) => {
const handleDownloadCover = async ( if (!coverUrl) {
coverUrl: string, toast.error("No cover URL found for this track");
trackName: string, return;
artistName: string,
albumName?: string,
playlistName?: string,
position?: number,
trackId?: string
) => {
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),
album: albumName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
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 id = trackId || `${trackName}-${artistName}`;
logger.info(`downloading cover: ${trackName} - ${artistName}`);
const response = await downloadCover({ const settings = getSettings();
cover_url: coverUrl, setDownloadingCover(true);
track_name: trackName, setDownloadingCoverTrack(id);
artist_name: artistName, try {
output_dir: outputDir, const os = settings.operatingSystem;
filename_format: settings.filenameTemplate || "{title}", let outputDir = settings.downloadPath;
track_number: settings.trackNumber, const placeholder = "__SLASH_PLACEHOLDER__";
position: position || 0, const templateData: TemplateData = {
}); artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
if (response.success) { title: trackName?.replace(/\//g, placeholder),
if (response.already_exists) { track: position,
toast.info("Cover file already exists"); playlist: playlistName?.replace(/\//g, placeholder),
setSkippedCovers((prev) => new Set(prev).add(id)); };
} else { if (playlistName && !isAlbum) {
toast.success("Cover downloaded successfully"); outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
setDownloadedCovers((prev) => new Set(prev).add(id)); }
} if (settings.folderTemplate) {
setFailedCovers((prev) => { const folderPath = parseTemplate(settings.folderTemplate, templateData);
const newSet = new Set(prev); if (folderPath) {
newSet.delete(id); const parts = folderPath.split("/").filter((p: string) => p.trim());
return newSet; for (const part of parts) {
}); const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
} else { outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
toast.error(response.error || "Failed to download cover"); }
setFailedCovers((prev) => new Set(prev).add(id)); }
} }
} catch (err) { const response = await downloadCover({
toast.error(err instanceof Error ? err.message : "Failed to download cover"); cover_url: coverUrl,
setFailedCovers((prev) => new Set(prev).add(id)); track_name: trackName,
} finally { artist_name: artistName,
setDownloadingCover(false); album_name: albumName || "",
setDownloadingCoverTrack(null); album_artist: albumArtist || "",
} release_date: releaseDate || "",
}; output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
const handleDownloadAllCovers = async ( track_number: settings.trackNumber,
tracks: TrackMetadata[], position: position || 0,
playlistName?: string disc_number: discNumber || 0,
) => { });
if (tracks.length === 0) { if (response.success) {
toast.error("No tracks to download covers"); if (response.already_exists) {
return; toast.info("Cover file already exists");
} setSkippedCovers((prev) => new Set(prev).add(id));
}
const settings = getSettings(); else {
setIsBulkDownloadingCovers(true); toast.success("Cover downloaded successfully");
setCoverDownloadProgress(0); setDownloadedCovers((prev) => new Set(prev).add(id));
stopBulkDownloadRef.current = false; }
setFailedCovers((prev) => {
let completed = 0; const newSet = new Set(prev);
let success = 0; newSet.delete(id);
let skipped = 0; return newSet;
let failed = 0; });
}
for (let i = 0; i < tracks.length; i++) { else {
if (stopBulkDownloadRef.current) { toast.error(response.error || "Failed to download cover");
toast.info("Cover download stopped"); setFailedCovers((prev) => new Set(prev).add(id));
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__";
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
track: i + 1,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
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));
} }
}
} }
catch (err) {
const response = await downloadCover({ toast.error(err instanceof Error ? err.message : "Failed to download cover");
cover_url: track.images, setFailedCovers((prev) => new Set(prev).add(id));
track_name: track.name,
artist_name: track.artists,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: i + 1,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedCovers((prev) => new Set(prev).add(id));
} else {
success++;
setDownloadedCovers((prev) => new Set(prev).add(id));
}
} else {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
} }
} catch { finally {
failed++; setDownloadingCover(false);
setFailedCovers((prev) => new Set(prev).add(id)); setDownloadingCoverTrack(null);
} }
};
completed++; const handleDownloadAllCovers = async (tracks: TrackMetadata[], playlistName?: string, isAlbum?: boolean) => {
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100)); if (tracks.length === 0) {
} toast.error("No tracks to download covers");
return;
setDownloadingCoverTrack(null); }
setIsBulkDownloadingCovers(false); const settings = getSettings();
setCoverDownloadProgress(0); setIsBulkDownloadingCovers(true);
setCoverDownloadProgress(0);
if (!stopBulkDownloadRef.current) { stopBulkDownloadRef.current = false;
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`); let completed = 0;
} let success = 0;
}; let skipped = 0;
let failed = 0;
const handleStopCoverDownload = () => { for (let i = 0; i < tracks.length; i++) {
stopBulkDownloadRef.current = true; if (stopBulkDownloadRef.current) {
}; toast.info("Cover download stopped");
break;
const resetCoverState = () => { }
setDownloadedCovers(new Set()); const track = tracks[i];
setFailedCovers(new Set()); if (!track.images) {
setSkippedCovers(new Set()); completed++;
}; setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
continue;
return { }
downloadingCover, const id = track.spotify_id || `${track.name}-${track.artists}`;
downloadingCoverTrack, setDownloadingCoverTrack(id);
downloadedCovers, try {
failedCovers, const os = settings.operatingSystem;
skippedCovers, let outputDir = settings.downloadPath;
isBulkDownloadingCovers, const placeholder = "__SLASH_PLACEHOLDER__";
coverDownloadProgress, const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
handleDownloadCover, const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
handleDownloadAllCovers, const templateData: TemplateData = {
handleStopCoverDownload, artist: track.artists?.replace(/\//g, placeholder),
resetCoverState, album: track.album_name?.replace(/\//g, placeholder),
}; title: track.name?.replace(/\//g, placeholder),
track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder),
};
if (playlistName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
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) {
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,
artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: trackPosition,
disc_number: track.disc_number,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedCovers((prev) => new Set(prev).add(id));
}
else {
success++;
setDownloadedCovers((prev) => new Set(prev).add(id));
}
}
else {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
}
}
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,
downloadedCovers,
failedCovers,
skippedCovers,
isBulkDownloadingCovers,
coverDownloadProgress,
handleDownloadCover,
handleDownloadAllCovers,
handleStopCoverDownload,
resetCoverState,
};
} }
File diff suppressed because it is too large Load Diff
+28 -38
View File
@@ -1,44 +1,34 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { GetDownloadProgress } from "../../wailsjs/go/main/App"; import { GetDownloadProgress } from "../../wailsjs/go/main/App";
export interface DownloadProgressInfo { export interface DownloadProgressInfo {
is_downloading: boolean; is_downloading: boolean;
mb_downloaded: number; mb_downloaded: number;
speed_mbps: number; speed_mbps: number;
} }
export function useDownloadProgress() { export function useDownloadProgress() {
const [progress, setProgress] = useState<DownloadProgressInfo>({ const [progress, setProgress] = useState<DownloadProgressInfo>({
is_downloading: false, is_downloading: false,
mb_downloaded: 0, mb_downloaded: 0,
speed_mbps: 0, speed_mbps: 0,
}); });
const intervalRef = useRef<number | null>(null); const intervalRef = useRef<number | null>(null);
useEffect(() => {
useEffect(() => { const pollProgress = async () => {
// Poll progress every 200ms for smooth updates try {
const pollProgress = async () => { const progressInfo = await GetDownloadProgress();
try { setProgress(progressInfo);
const progressInfo = await GetDownloadProgress(); }
setProgress(progressInfo); catch (error) {
} catch (error) { console.error("Failed to get download progress:", error);
console.error("Failed to get download progress:", error); }
} };
}; intervalRef.current = window.setInterval(pollProgress, 200);
pollProgress();
// Start polling return () => {
intervalRef.current = window.setInterval(pollProgress, 200); if (intervalRef.current) {
clearInterval(intervalRef.current);
// Initial fetch }
pollProgress(); };
}, []);
// Cleanup return progress;
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return progress;
} }
+26 -35
View File
@@ -1,40 +1,31 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { GetDownloadQueue } from "../../wailsjs/go/main/App"; import { GetDownloadQueue } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models"; import { backend } from "../../wailsjs/go/models";
export function useDownloadQueueData() { export function useDownloadQueueData() {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>( const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
new backend.DownloadQueueInfo({ is_downloading: false,
is_downloading: false, queue: [],
queue: [], current_speed: 0,
current_speed: 0, total_downloaded: 0,
total_downloaded: 0, session_start_time: 0,
session_start_time: 0, queued_count: 0,
queued_count: 0, completed_count: 0,
completed_count: 0, failed_count: 0,
failed_count: 0, skipped_count: 0,
skipped_count: 0, }));
}) useEffect(() => {
); const fetchQueue = async () => {
try {
useEffect(() => { const info = await GetDownloadQueue();
const fetchQueue = async () => { setQueueInfo(info);
try { }
const info = await GetDownloadQueue(); catch (error) {
setQueueInfo(info); console.error("Failed to get download queue:", error);
} catch (error) { }
console.error("Failed to get download queue:", error); };
} fetchQueue();
}; const interval = setInterval(fetchQueue, 200);
return () => clearInterval(interval);
// Initial fetch }, []);
fetchQueue(); return queueInfo;
// Poll every 200ms
const interval = setInterval(fetchQueue, 200);
return () => clearInterval(interval);
}, []);
return queueInfo;
} }
+10 -13
View File
@@ -1,16 +1,13 @@
import { useState } from "react"; import { useState } from "react";
export function useDownloadQueueDialog() { export function useDownloadQueueDialog() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const openQueue = () => setIsOpen(true);
const openQueue = () => setIsOpen(true); const closeQueue = () => setIsOpen(false);
const closeQueue = () => setIsOpen(false); const toggleQueue = () => setIsOpen((prev) => !prev);
const toggleQueue = () => setIsOpen((prev) => !prev); return {
isOpen,
return { openQueue,
isOpen, closeQueue,
openQueue, toggleQueue,
closeQueue, };
toggleQueue,
};
} }
+198 -231
View File
@@ -5,240 +5,207 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils"; import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata } from "@/types/api";
export function useLyrics() { export function useLyrics() {
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null); const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set()); const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set()); const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set()); const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false); const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0); const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
const stopBulkDownloadRef = useRef(false); 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, isAlbum?: boolean) => {
const handleDownloadLyrics = async ( if (!spotifyId) {
spotifyId: string, toast.error("No Spotify ID found for this track");
trackName: string, return;
artistName: string,
albumName?: string,
playlistName?: string,
position?: number
) => {
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),
album: albumName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
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));
}
} }
} logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
const settings = getSettings();
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; setDownloadingLyricsTrack(spotifyId);
try {
const response = await downloadLyrics({ const os = settings.operatingSystem;
spotify_id: spotifyId, let outputDir = settings.downloadPath;
track_name: trackName, const placeholder = "__SLASH_PLACEHOLDER__";
artist_name: artistName, const templateData: TemplateData = {
output_dir: outputDir, artist: artistName?.replace(/\//g, placeholder),
filename_format: settings.filenameTemplate || "{title}", album: albumName?.replace(/\//g, placeholder),
track_number: settings.trackNumber, title: trackName?.replace(/\//g, placeholder),
position: position || 0, track: position,
use_album_track_number: useAlbumTrackNumber, playlist: playlistName?.replace(/\//g, placeholder),
}); };
if (playlistName && !isAlbum) {
if (response.success) { outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
if (response.already_exists) { }
toast.info("Lyrics file already exists"); if (settings.folderTemplate) {
setSkippedLyrics((prev) => new Set(prev).add(spotifyId)); const folderPath = parseTemplate(settings.folderTemplate, templateData);
} else { if (folderPath) {
toast.success("Lyrics downloaded successfully"); const parts = folderPath.split("/").filter((p: string) => p.trim());
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId)); for (const part of parts) {
} const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
setFailedLyrics((prev) => { outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
const newSet = new Set(prev); }
newSet.delete(spotifyId); }
return newSet; }
}); const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
} else { const response = await downloadLyrics({
toast.error(response.error || "Failed to download lyrics"); spotify_id: spotifyId,
setFailedLyrics((prev) => new Set(prev).add(spotifyId)); track_name: trackName,
} artist_name: artistName,
} catch (err) { album_name: albumName,
toast.error(err instanceof Error ? err.message : "Failed to download lyrics"); album_artist: albumArtist,
setFailedLyrics((prev) => new Set(prev).add(spotifyId)); release_date: releaseDate,
} finally { output_dir: outputDir,
setDownloadingLyricsTrack(null); filename_format: settings.filenameTemplate || "{title}",
} track_number: settings.trackNumber,
}; position: position || 0,
use_album_track_number: useAlbumTrackNumber,
const handleDownloadAllLyrics = async ( disc_number: discNumber,
tracks: TrackMetadata[], });
playlistName?: string, if (response.success) {
_isArtistDiscography?: boolean if (response.already_exists) {
) => { toast.info("Lyrics file already exists");
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id); setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
}
if (tracksWithSpotifyId.length === 0) { else {
toast.error("No tracks with Spotify ID available for lyrics download"); toast.success("Lyrics downloaded successfully");
return; setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
} }
setFailedLyrics((prev) => {
const settings = getSettings(); const newSet = new Set(prev);
setIsBulkDownloadingLyrics(true); newSet.delete(spotifyId);
setLyricsDownloadProgress(0); return newSet;
stopBulkDownloadRef.current = false; });
}
let completed = 0; else {
let success = 0; toast.error(response.error || "Failed to download lyrics");
let failed = 0; setFailedLyrics((prev) => new Set(prev).add(spotifyId));
let skipped = 0;
const total = tracksWithSpotifyId.length;
for (const track of tracksWithSpotifyId) {
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__";
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
track: track.track_number,
playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
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));
} }
}
} }
catch (err) {
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
const response = await downloadLyrics({
spotify_id: id,
track_name: track.name,
artist_name: track.artists,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: track.track_number || 0,
use_album_track_number: useAlbumTrackNumber,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedLyrics((prev) => new Set(prev).add(id));
} else {
success++;
setDownloadedLyrics((prev) => new Set(prev).add(id));
}
setFailedLyrics((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
} else {
failed++;
setFailedLyrics((prev) => new Set(prev).add(id));
} }
} catch (err) { finally {
failed++; setDownloadingLyricsTrack(null);
logger.error(`error downloading lyrics: ${track.name} - ${err}`); }
setFailedLyrics((prev) => new Set(prev).add(id)); };
} const handleDownloadAllLyrics = async (tracks: TrackMetadata[], playlistName?: string, _isArtistDiscography?: boolean, isAlbum?: boolean) => {
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
completed++; if (tracksWithSpotifyId.length === 0) {
} toast.error("No tracks with Spotify ID available for lyrics download");
return;
setDownloadingLyricsTrack(null); }
setIsBulkDownloadingLyrics(false); const settings = getSettings();
setLyricsDownloadProgress(0); setIsBulkDownloadingLyrics(true);
setLyricsDownloadProgress(0);
if (!stopBulkDownloadRef.current) { stopBulkDownloadRef.current = false;
toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`); let completed = 0;
} let success = 0;
}; let failed = 0;
let skipped = 0;
const handleStopLyricsDownload = () => { const total = tracksWithSpotifyId.length;
logger.info("lyrics download stopped by user"); for (let i = 0; i < tracksWithSpotifyId.length; i++) {
stopBulkDownloadRef.current = true; const track = tracksWithSpotifyId[i];
toast.info("Stopping lyrics download..."); if (stopBulkDownloadRef.current) {
}; toast.info("Lyrics download stopped by user");
break;
const resetLyricsState = () => { }
setDownloadedLyrics(new Set()); const id = track.spotify_id!;
setFailedLyrics(new Set()); setDownloadingLyricsTrack(id);
setSkippedLyrics(new Set()); setLyricsDownloadProgress(Math.round((completed / total) * 100));
}; try {
const os = settings.operatingSystem;
return { let outputDir = settings.downloadPath;
downloadingLyricsTrack, const placeholder = "__SLASH_PLACEHOLDER__";
downloadedLyrics, const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
failedLyrics, const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
skippedLyrics, const templateData: TemplateData = {
isBulkDownloadingLyrics, artist: track.artists?.replace(/\//g, placeholder),
lyricsDownloadProgress, album: track.album_name?.replace(/\//g, placeholder),
handleDownloadLyrics, title: track.name?.replace(/\//g, placeholder),
handleDownloadAllLyrics, track: trackPosition,
handleStopLyricsDownload, playlist: playlistName?.replace(/\//g, placeholder),
resetLyricsState, };
}; if (playlistName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
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) {
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,
artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: trackPosition,
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 {
success++;
setDownloadedLyrics((prev) => new Set(prev).add(id));
}
setFailedLyrics((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
else {
failed++;
setFailedLyrics((prev) => new Set(prev).add(id));
}
}
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,
failedLyrics,
skippedLyrics,
isBulkDownloadingLyrics,
lyricsDownloadProgress,
handleDownloadLyrics,
handleDownloadAllLyrics,
handleStopLyricsDownload,
resetLyricsState,
};
} }
+210 -197
View File
@@ -3,202 +3,215 @@ import { fetchSpotifyMetadata } from "@/lib/api";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { SpotifyMetadataResponse } from "@/types/api"; import type { SpotifyMetadataResponse } from "@/types/api";
export function useMetadata() { export function useMetadata() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null); const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false); const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
const [timeoutValue, setTimeoutValue] = useState(60); const [timeoutValue, setTimeoutValue] = useState(60);
const [pendingUrl, setPendingUrl] = useState(""); const [pendingUrl, setPendingUrl] = useState("");
const [showAlbumDialog, setShowAlbumDialog] = useState(false); const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{ const [selectedAlbum, setSelectedAlbum] = useState<{
id: string; id: string;
name: string; name: string;
external_urls: string; external_urls: string;
} | null>(null); } | null>(null);
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null); const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
const getUrlType = (url: string): string => {
const getUrlType = (url: string): string => { if (url.includes("/track/"))
if (url.includes("/track/")) return "track"; return "track";
if (url.includes("/album/")) return "album"; if (url.includes("/album/"))
if (url.includes("/playlist/")) return "playlist"; return "album";
if (url.includes("/artist/")) return "artist"; if (url.includes("/playlist/"))
return "unknown"; return "playlist";
}; if (url.includes("/artist/"))
return "artist";
const fetchMetadataDirectly = async (url: string) => { return "unknown";
const urlType = getUrlType(url); };
logger.info(`fetching ${urlType} metadata...`); const fetchMetadataDirectly = async (url: string) => {
logger.debug(`url: ${url}`); const urlType = getUrlType(url);
logger.info(`fetching ${urlType} metadata...`);
setLoading(true); logger.debug(`url: ${url}`);
setMetadata(null); setLoading(true);
setMetadata(null);
try { try {
const startTime = Date.now(); const startTime = Date.now();
const data = await fetchSpotifyMetadata(url); const data = await fetchSpotifyMetadata(url);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
if ("playlist_info" in data) {
setMetadata(data); const playlistInfo = data.playlist_info;
if (!playlistInfo.owner.name && playlistInfo.tracks.total === 0 && data.track_list.length === 0) {
// Log detailed info based on type logger.warning("playlist appears to be empty or private");
if ("track" in data) { toast.error("Playlist not found or may be private");
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`); setMetadata(null);
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`); return;
} 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 ("album_info" in data) {
} else if ("playlist_info" in data) { const albumInfo = data.album_info;
logger.success(`fetched playlist: ${data.track_list.length} tracks`); if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`); logger.warning("album appears to be empty or not found");
} else if ("artist_info" in data) { toast.error("Album not found or may be private");
logger.success(`fetched artist: ${data.artist_info.name}`); setMetadata(null);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`); return;
} }
}
logger.info(`fetch completed in ${elapsed}s`); setMetadata(data);
toast.success("Metadata fetched successfully"); if ("track" in data) {
} catch (err) { logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
logger.error(`fetch failed: ${errorMsg}`); }
toast.error(errorMsg); else if ("album_info" in data) {
} finally { logger.success(`fetched album: ${data.album_info.name}`);
setLoading(false); logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
} }
}; else if ("playlist_info" in data) {
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
const handleFetchMetadata = async (url: string) => { logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
if (!url.trim()) { }
logger.warning("empty url provided"); else if ("artist_info" in data) {
toast.error("Please enter a Spotify URL"); logger.success(`fetched artist: ${data.artist_info.name}`);
return; logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
} }
logger.info(`fetch completed in ${elapsed}s`);
let urlToFetch = url.trim(); toast.success("Metadata fetched successfully");
const isArtistUrl = urlToFetch.includes("/artist/"); }
catch (err) {
if (isArtistUrl && !urlToFetch.includes("/discography")) { const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all"; logger.error(`fetch failed: ${errorMsg}`);
logger.debug("converted to discography url"); toast.error(errorMsg);
} }
finally {
if (isArtistUrl) { setLoading(false);
logger.info("artist url detected, showing timeout dialog"); }
setPendingUrl(urlToFetch); };
setPendingArtistName(null); // Clear artist name for URL input const handleFetchMetadata = async (url: string) => {
setShowTimeoutDialog(true); if (!url.trim()) {
} else { logger.warning("empty url provided");
await fetchMetadataDirectly(urlToFetch); toast.error("Please enter a Spotify URL");
} return;
}
return urlToFetch; let urlToFetch = url.trim();
}; const isArtistUrl = urlToFetch.includes("/artist/");
if (isArtistUrl && !urlToFetch.includes("/discography")) {
const handleConfirmFetch = async () => { urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
setShowTimeoutDialog(false); logger.debug("converted to discography url");
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`); }
logger.debug(`url: ${pendingUrl}`); if (isArtistUrl) {
logger.info("artist url detected, showing timeout dialog");
setLoading(true); setPendingUrl(urlToFetch);
setMetadata(null); setPendingArtistName(null);
setShowTimeoutDialog(true);
try { }
const startTime = Date.now(); else {
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue); await fetchMetadataDirectly(urlToFetch);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); }
return urlToFetch;
setMetadata(data); };
const handleConfirmFetch = async () => {
if ("artist_info" in data) { setShowTimeoutDialog(false);
logger.success(`fetched artist: ${data.artist_info.name}`); logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`); logger.debug(`url: ${pendingUrl}`);
} setLoading(true);
setMetadata(null);
logger.info(`fetch completed in ${elapsed}s`); try {
toast.success("Metadata fetched successfully"); const startTime = Date.now();
} catch (err) { const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
logger.error(`fetch failed: ${errorMsg}`); setMetadata(data);
toast.error(errorMsg); if ("artist_info" in data) {
} finally { logger.success(`fetched artist: ${data.artist_info.name}`);
setLoading(false); logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
} }
}; logger.info(`fetch completed in ${elapsed}s`);
toast.success("Metadata fetched successfully");
const handleAlbumClick = (album: { }
id: string; catch (err) {
name: string; const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
external_urls: string; logger.error(`fetch failed: ${errorMsg}`);
}) => { toast.error(errorMsg);
logger.debug(`album clicked: ${album.name}`); }
setSelectedAlbum(album); finally {
setShowAlbumDialog(true); setLoading(false);
}; }
};
const handleArtistClick = async (artist: { const handleAlbumClick = (album: {
id: string; id: string;
name: string; name: string;
external_urls: string; external_urls: string;
}) => { }) => {
logger.debug(`artist clicked: ${artist.name}`); logger.debug(`album clicked: ${album.name}`);
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSelectedAlbum(album);
setPendingUrl(artistUrl); setShowAlbumDialog(true);
setPendingArtistName(artist.name); };
setShowTimeoutDialog(true); const handleArtistClick = async (artist: {
return artistUrl; id: string;
}; name: string;
external_urls: string;
const handleConfirmAlbumFetch = async () => { }) => {
if (!selectedAlbum) return; logger.debug(`artist clicked: ${artist.name}`);
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
const albumUrl = selectedAlbum.external_urls; setPendingUrl(artistUrl);
logger.info(`fetching album: ${selectedAlbum.name}...`); setPendingArtistName(artist.name);
logger.debug(`url: ${albumUrl}`); setShowTimeoutDialog(true);
return artistUrl;
setShowAlbumDialog(false); };
setLoading(true); const handleConfirmAlbumFetch = async () => {
setMetadata(null); if (!selectedAlbum)
return;
try { const albumUrl = selectedAlbum.external_urls;
const startTime = Date.now(); logger.info(`fetching album: ${selectedAlbum.name}...`);
const data = await fetchSpotifyMetadata(albumUrl); logger.debug(`url: ${albumUrl}`);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); setShowAlbumDialog(false);
setLoading(true);
setMetadata(data); setMetadata(null);
try {
if ("album_info" in data) { const startTime = Date.now();
logger.success(`fetched album: ${data.album_info.name}`); const data = await fetchSpotifyMetadata(albumUrl);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`); const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
} if ("album_info" in data) {
const albumInfo = data.album_info;
logger.info(`fetch completed in ${elapsed}s`); if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
toast.success("Album metadata fetched successfully"); logger.warning("album appears to be empty or not found");
return albumUrl; toast.error("Album not found or may be private");
} catch (err) { setMetadata(null);
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata"; setSelectedAlbum(null);
logger.error(`fetch failed: ${errorMsg}`); return albumUrl;
toast.error(errorMsg); }
} finally { }
setLoading(false); setMetadata(data);
setSelectedAlbum(null); 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}`);
}
return { logger.info(`fetch completed in ${elapsed}s`);
loading, toast.success("Album metadata fetched successfully");
metadata, return albumUrl;
showTimeoutDialog, }
setShowTimeoutDialog, catch (err) {
timeoutValue, const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
setTimeoutValue, logger.error(`fetch failed: ${errorMsg}`);
showAlbumDialog, toast.error(errorMsg);
setShowAlbumDialog, }
selectedAlbum, finally {
pendingArtistName, setLoading(false);
handleFetchMetadata, setSelectedAlbum(null);
handleConfirmFetch, }
handleAlbumClick, };
handleConfirmAlbumFetch, return {
handleArtistClick, loading,
}; metadata,
showTimeoutDialog,
setShowTimeoutDialog,
timeoutValue,
setTimeoutValue,
showAlbumDialog,
setShowAlbumDialog,
selectedAlbum,
pendingArtistName,
handleFetchMetadata,
handleConfirmFetch,
handleAlbumClick,
handleConfirmAlbumFetch,
handleArtistClick,
};
} }
+1 -1
View File
@@ -77,7 +77,7 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
code, pre, .font-mono { code, pre, .font-mono {
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
+36 -52
View File
@@ -1,59 +1,43 @@
import type { import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api";
SpotifyMetadataResponse, import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App";
DownloadRequest,
DownloadResponse,
HealthResponse,
LyricsDownloadRequest,
LyricsDownloadResponse,
CoverDownloadRequest,
CoverDownloadResponse,
} from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
import { main } from "../../wailsjs/go/models"; 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( const req = new main.SpotifyMetadataRequest({
url: string, url,
batch: boolean = true, batch,
delay: number = 1.0, delay,
timeout: number = 300.0 timeout,
): Promise<SpotifyMetadataResponse> { });
const req = new main.SpotifyMetadataRequest({ const jsonString = await GetSpotifyMetadata(req);
url, return JSON.parse(jsonString);
batch,
delay,
timeout,
});
const jsonString = await GetSpotifyMetadata(req);
return JSON.parse(jsonString);
} }
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
export async function downloadTrack( const req = new main.DownloadRequest(request);
request: DownloadRequest return await DownloadTrack(req);
): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request);
return await DownloadTrack(req);
} }
export async function checkHealth(): Promise<HealthResponse> { export async function checkHealth(): Promise<HealthResponse> {
// For Wails, we can just return a simple health check return {
// since the app is running locally status: "ok",
return { time: new Date().toISOString(),
status: "ok", };
time: new Date().toISOString(),
};
} }
export async function downloadLyrics(request: LyricsDownloadRequest): Promise<LyricsDownloadResponse> {
export async function downloadLyrics( const req = new main.LyricsDownloadRequest(request);
request: LyricsDownloadRequest return await DownloadLyrics(req);
): Promise<LyricsDownloadResponse> {
const req = new main.LyricsDownloadRequest(request);
return await DownloadLyrics(req);
} }
export async function downloadCover(request: CoverDownloadRequest): Promise<CoverDownloadResponse> {
export async function downloadCover( const req = new main.CoverDownloadRequest(request);
request: CoverDownloadRequest return await DownloadCover(req);
): Promise<CoverDownloadResponse> { }
const req = new main.CoverDownloadRequest(request); export async function downloadHeader(request: HeaderDownloadRequest): Promise<HeaderDownloadResponse> {
return await DownloadCover(req); 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);
} }
+62 -97
View File
@@ -1,106 +1,71 @@
// Audio utility for toast notifications using Web Audio API
class AudioManager { class AudioManager {
private audioContext: AudioContext | null = null; private audioContext: AudioContext | null = null;
private getAudioContext(): AudioContext {
private getAudioContext(): AudioContext { if (!this.audioContext) {
if (!this.audioContext) { this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); }
return this.audioContext;
} }
return this.audioContext; private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
} try {
const ctx = this.getAudioContext();
// Generate a simple tone using oscillator const oscillator = ctx.createOscillator();
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) { const gainNode = ctx.createGain();
try { oscillator.connect(gainNode);
const ctx = this.getAudioContext(); gainNode.connect(ctx.destination);
const oscillator = ctx.createOscillator(); oscillator.frequency.value = frequency;
const gainNode = ctx.createGain(); oscillator.type = type;
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
oscillator.connect(gainNode); gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
gainNode.connect(ctx.destination); oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
oscillator.frequency.value = frequency; }
oscillator.type = type; catch (error) {
console.error('Error playing audio:', error);
gainNode.gain.setValueAtTime(volume, ctx.currentTime); }
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
} catch (error) {
console.error('Error playing audio:', error);
} }
} playSuccess() {
const ctx = this.getAudioContext();
// Success sound - pleasant ascending tones const now = ctx.currentTime;
playSuccess() { this.playToneAt(523.25, 0.08, 'sine', 0.2, now);
const ctx = this.getAudioContext(); this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08);
const now = ctx.currentTime; this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16);
}
// First tone playError() {
this.playToneAt(523.25, 0.08, 'sine', 0.2, now); // C5 const ctx = this.getAudioContext();
// Second tone const now = ctx.currentTime;
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); // E5 this.playToneAt(392.00, 0.1, 'square', 0.15, now);
// Third tone this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1);
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); // G5 }
} playWarning() {
const ctx = this.getAudioContext();
// Error sound - descending tones const now = ctx.currentTime;
playError() { this.playToneAt(440.00, 0.1, 'triangle', 0.2, now);
const ctx = this.getAudioContext(); this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12);
const now = ctx.currentTime; }
playInfo() {
// First tone this.playTone(523.25, 0.15, 'sine', 0.15);
this.playToneAt(392.00, 0.1, 'square', 0.15, now); // G4 }
// Second tone private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); // E4 try {
} const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
// Warning sound - alternating tones const gainNode = ctx.createGain();
playWarning() { oscillator.connect(gainNode);
const ctx = this.getAudioContext(); gainNode.connect(ctx.destination);
const now = ctx.currentTime; oscillator.frequency.value = frequency;
oscillator.type = type;
// First tone gainNode.gain.setValueAtTime(volume, startTime);
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); // A4 gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
// Second tone oscillator.start(startTime);
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); // B4 oscillator.stop(startTime + duration);
} }
catch (error) {
// Info sound - single soft tone console.error('Error playing audio:', error);
playInfo() { }
this.playTone(523.25, 0.15, 'sine', 0.15); // C5
}
// Helper method to play tone at specific time
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
} catch (error) {
console.error('Error playing audio:', error);
} }
}
} }
// Export singleton instance
export const audioManager = new AudioManager(); export const audioManager = new AudioManager();
// Helper functions for easy use
export const playSuccessSound = () => audioManager.playSuccess(); export const playSuccessSound = () => audioManager.playSuccess();
export const playErrorSound = () => audioManager.playError(); export const playErrorSound = () => audioManager.playError();
export const playWarningSound = () => audioManager.playWarning(); export const playWarningSound = () => audioManager.playWarning();
+46 -59
View File
@@ -1,66 +1,53 @@
export type LogLevel = "info" | "success" | "warning" | "error" | "debug"; export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
export interface LogEntry { export interface LogEntry {
timestamp: Date; timestamp: Date;
level: LogLevel; level: LogLevel;
message: string; message: string;
} }
class Logger { class Logger {
private logs: LogEntry[] = []; private logs: LogEntry[] = [];
private maxLogs = 500; private maxLogs = 500;
private listeners: Set<() => void> = new Set(); private listeners: Set<() => void> = new Set();
private addLog(level: LogLevel, message: string) {
private addLog(level: LogLevel, message: string) { const entry: LogEntry = {
const entry: LogEntry = { timestamp: new Date(),
timestamp: new Date(), level,
level, message: message.toLowerCase(),
message: message.toLowerCase(), };
}; this.logs.push(entry);
this.logs.push(entry); if (this.logs.length > this.maxLogs) {
if (this.logs.length > this.maxLogs) { this.logs.shift();
this.logs.shift(); }
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());
} }
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(); export const logger = new Logger();
+50 -52
View File
@@ -1,59 +1,57 @@
/**
* 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 { export function formatRelativeTime(date: Date | string | number): string {
const now = new Date(); const now = new Date();
const target = new Date(date); const target = new Date(date);
const diffMs = now.getTime() - target.getTime(); const diffMs = now.getTime() - target.getTime();
if (diffMs < 0)
if (diffMs < 0) return "just now"; return "just now";
const seconds = Math.floor(diffMs / 1000);
const seconds = Math.floor(diffMs / 1000); const minutes = Math.floor(seconds / 60);
const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60);
const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24);
const days = Math.floor(hours / 24); const weeks = Math.floor(days / 7);
const weeks = Math.floor(days / 7); const months = Math.floor(days / 30);
const months = Math.floor(days / 30); const years = Math.floor(days / 365);
const years = Math.floor(days / 365); const parts: string[] = [];
if (years > 0) {
const parts: string[] = []; parts.push(`${years} ${years === 1 ? "year" : "years"}`);
const remainingMonths = Math.floor((days % 365) / 30);
if (years > 0) { if (remainingMonths > 0) {
parts.push(`${years} ${years === 1 ? "year" : "years"}`); parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
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"}`); parts.push(`${months} ${months === 1 ? "month" : "months"}`);
const remainingDays = days % 30; const remainingDays = days % 30;
if (remainingDays > 0) { if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`); parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} }
} else if (weeks > 0) { else if (weeks > 0) {
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`); parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
const remainingDays = days % 7; const remainingDays = days % 7;
if (remainingDays > 0) { if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`); parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} }
} else if (days > 0) { else if (days > 0) {
parts.push(`${days} ${days === 1 ? "day" : "days"}`); parts.push(`${days} ${days === 1 ? "day" : "days"}`);
const remainingHours = hours % 24; const remainingHours = hours % 24;
if (remainingHours > 0) { if (remainingHours > 0) {
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`); parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
}
} }
} else if (hours > 0) { else if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`); parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
const remainingMinutes = minutes % 60; const remainingMinutes = minutes % 60;
if (remainingMinutes > 0) { if (remainingMinutes > 0) {
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`); parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
}
} }
} else if (minutes > 0) { else if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`); parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
} else { }
return "just now"; else {
} return "just now";
}
return "Released " + parts.slice(0, 2).join(" ") + " ago"; return "Released " + parts.slice(0, 2).join(" ") + " ago";
} }
+238 -256
View File
@@ -1,287 +1,269 @@
import { GetDefaults } from "../../wailsjs/go/main/App"; 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"; 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
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom"; export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
// 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 type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export interface Settings { export interface Settings {
downloadPath: string; downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon"; downloader: "auto" | "tidal" | "qobuz" | "amazon";
theme: string; theme: string;
themeMode: "auto" | "light" | "dark"; themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily; fontFamily: FontFamily;
// New template system folderPreset: FolderPreset;
folderPreset: FolderPreset; folderTemplate: string;
folderTemplate: string; filenamePreset: FilenamePreset;
filenamePreset: FilenamePreset; filenameTemplate: string;
filenameTemplate: string; filenameFormat?: "title-artist" | "artist-title" | "title";
// Legacy settings (kept for migration) artistSubfolder?: boolean;
filenameFormat?: "title-artist" | "artist-title" | "title"; albumSubfolder?: boolean;
artistSubfolder?: boolean; trackNumber: boolean;
albumSubfolder?: boolean; sfxEnabled: boolean;
trackNumber: boolean; embedLyrics: boolean;
sfxEnabled: boolean; embedMaxQualityCover: boolean;
embedLyrics: boolean; operatingSystem: "Windows" | "linux/MacOS";
embedMaxQualityCover: boolean; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
operatingSystem: "Windows" | "linux/MacOS"; qobuzQuality: "6" | "7" | "27";
// Quality settings for specific sources amazonQuality: "HI_RES";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
} }
export const FOLDER_PRESETS: Record<FolderPreset, {
// Folder preset templates label: string;
export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: string }> = { template: string;
"none": { label: "No Subfolder", template: "" }, }> = {
"artist": { label: "Artist", template: "{artist}" }, "none": { label: "No Subfolder", template: "" },
"album": { label: "Album", template: "{album}" }, "artist": { label: "Artist", template: "{artist}" },
"year-album": { label: "[Year] Album", template: "[{year}] {album}" }, "album": { label: "Album", template: "{album}" },
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" }, "year-album": { label: "[Year] Album", template: "[{year}] {album}" },
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" }, "year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" }, "artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" }, "artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"album-artist": { label: "Album Artist", template: "{album_artist}" }, "artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" }, "album-artist": { label: "Album Artist", template: "{album_artist}" },
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" }, "album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" }, "album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
"year": { label: "Year", template: "{year}" }, "album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" }, "year": { label: "Year", template: "{year}" },
"custom": { label: "Custom...", template: "{artist}/{album}" }, "year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
"custom": { label: "Custom...", template: "{artist}/{album}" },
}; };
export const FILENAME_PRESETS: Record<FilenamePreset, {
// Filename preset templates label: string;
export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template: string }> = { template: string;
"title": { label: "Title", template: "{title}" }, }> = {
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" }, "title": { label: "Title", template: "{title}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" }, "title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" }, "artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" }, "track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" }, "track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" }, "track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" }, "title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" }, "track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" }, "artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" }, "track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" }, "disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"custom": { label: "Custom...", template: "{title} - {artist}" }, "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 = [ export const TEMPLATE_VARIABLES = [
{ key: "{title}", description: "Track title", example: "Shake It Off" }, { key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" }, { key: "{artist}", description: "Track artist", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" }, { key: "{album}", description: "Album name", example: "1989" },
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" }, { key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
{ key: "{track}", description: "Track number", example: "01" }, { key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" }, { key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" }, { key: "{year}", description: "Release year", example: "2014" },
]; ];
// Auto-detect operating system
function detectOS(): "Windows" | "linux/MacOS" { function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase(); const platform = window.navigator.platform.toLowerCase();
if (platform.includes('win')) { if (platform.includes('win')) {
return "Windows"; return "Windows";
} }
return "linux/MacOS"; return "linux/MacOS";
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
downloadPath: "", downloadPath: "",
downloader: "auto", downloader: "auto",
theme: "yellow", theme: "yellow",
themeMode: "auto", themeMode: "auto",
fontFamily: "google-sans", fontFamily: "google-sans",
folderPreset: "none", folderPreset: "none",
folderTemplate: "", folderTemplate: "",
filenamePreset: "title-artist", filenamePreset: "title-artist",
filenameTemplate: "{title} - {artist}", filenameTemplate: "{title} - {artist}",
trackNumber: false, trackNumber: false,
sfxEnabled: true, sfxEnabled: true,
embedLyrics: false, embedLyrics: false,
embedMaxQualityCover: false, embedMaxQualityCover: false,
operatingSystem: detectOS(), operatingSystem: detectOS(),
tidalQuality: "LOSSLESS", // Default: 16-bit lossless tidalQuality: "LOSSLESS",
qobuzQuality: "6" // Default: FLAC 16-bit qobuzQuality: "6",
amazonQuality: "HI_RES"
}; };
export const FONT_OPTIONS: {
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [ value: FontFamily;
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' }, label: string;
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' }, fontFamily: string;
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' }, }[] = [
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' }, { value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' }, { value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' }, { value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' }, { value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' }, { value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' }, { value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' }, { value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' }, { value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' }, { value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' }, { value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' }, { value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' }, { value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' }, { value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
]; ];
export function applyFont(fontFamily: FontFamily): void { export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily); const font = FONT_OPTIONS.find(f => f.value === fontFamily);
if (font) { if (font) {
document.documentElement.style.setProperty('--font-sans', font.fontFamily); document.documentElement.style.setProperty('--font-sans', font.fontFamily);
document.body.style.fontFamily = font.fontFamily; document.body.style.fontFamily = font.fontFamily;
} }
} }
async function fetchDefaultPath(): Promise<string> { async function fetchDefaultPath(): Promise<string> {
try { try {
const data = await GetDefaults(); const data = await GetDefaults();
return data.downloadPath || ""; return data.downloadPath || "";
} catch (error) { }
console.error("Failed to fetch default path:", error); catch (error) {
return ""; console.error("Failed to fetch default path:", error);
} return "";
}
} }
const SETTINGS_KEY = "spotiflac-settings"; const SETTINGS_KEY = "spotiflac-settings";
export function getSettings(): Settings { export function getSettings(): Settings {
try { try {
const stored = localStorage.getItem(SETTINGS_KEY); const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) { if (stored) {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
// Migrate old darkMode to themeMode if ('darkMode' in parsed && !('themeMode' in parsed)) {
if ('darkMode' in parsed && !('themeMode' in parsed)) { parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
parsed.themeMode = parsed.darkMode ? 'dark' : 'light'; delete parsed.darkMode;
delete parsed.darkMode; }
} if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
// Migrate old folder/filename settings to new template system const hasArtist = parsed.artistSubfolder;
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) { const hasAlbum = parsed.albumSubfolder;
const hasArtist = parsed.artistSubfolder; if (hasArtist && hasAlbum) {
const hasAlbum = parsed.albumSubfolder; parsed.folderPreset = "artist-album";
if (hasArtist && hasAlbum) { parsed.folderTemplate = "{artist}/{album}";
parsed.folderPreset = "artist-album"; }
parsed.folderTemplate = "{artist}/{album}"; else if (hasArtist) {
} else if (hasArtist) { parsed.folderPreset = "artist";
parsed.folderPreset = "artist"; parsed.folderTemplate = "{artist}";
parsed.folderTemplate = "{artist}"; }
} else if (hasAlbum) { else if (hasAlbum) {
parsed.folderPreset = "album"; parsed.folderPreset = "album";
parsed.folderTemplate = "{album}"; parsed.folderTemplate = "{album}";
} else { }
parsed.folderPreset = "none"; else {
parsed.folderTemplate = ""; 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 (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "HI_RES";
}
return { ...DEFAULT_SETTINGS, ...parsed };
} }
}
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}";
}
}
// 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";
}
return { ...DEFAULT_SETTINGS, ...parsed };
} }
} catch (error) { catch (error) {
console.error("Failed to load settings:", error); console.error("Failed to load settings:", error);
} }
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
// Parse template and replace variables with actual values
export interface TemplateData { export interface TemplateData {
artist?: string; artist?: string;
album?: string; album?: string;
album_artist?: string; album_artist?: string;
title?: string; title?: string;
track?: number; track?: number;
disc?: number; disc?: number;
year?: string; year?: string;
isrc?: string; playlist?: string;
playlist?: string;
} }
export function parseTemplate(template: string, data: TemplateData): string { export function parseTemplate(template: string, data: TemplateData): string {
if (!template) return ""; if (!template)
return "";
let result = template; let result = template;
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
// Replace each variable result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{title\}/g, data.title || "Unknown Title"); result = result.replace(/\{album\}/g, data.album || "Unknown Album");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist"); result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album"); result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist"); result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00"); result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1"); result = result.replace(/\{playlist\}/g, data.playlist || "");
result = result.replace(/\{year\}/g, data.year || "0000"); return result;
result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{playlist\}/g, data.playlist || "");
return result;
} }
export async function getSettingsWithDefaults(): Promise<Settings> { export async function getSettingsWithDefaults(): Promise<Settings> {
const settings = getSettings(); const settings = getSettings();
if (!settings.downloadPath) {
// If downloadPath is empty, fetch from backend settings.downloadPath = await fetchDefaultPath();
if (!settings.downloadPath) { }
settings.downloadPath = await fetchDefaultPath(); return settings;
} }
export function saveSettings(settings: Settings): void {
return settings; try {
} localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
export function saveSettings(settings: Settings): void { catch (error) {
try { console.error("Failed to save settings:", error);
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); }
} catch (error) { }
console.error("Failed to save settings:", error); export function updateSettings(partial: Partial<Settings>): Settings {
} const current = getSettings();
} const updated = { ...current, ...partial };
saveSettings(updated);
export function updateSettings(partial: Partial<Settings>): Settings { return updated;
const current = getSettings(); }
const updated = { ...current, ...partial }; export async function resetToDefaultSettings(): Promise<Settings> {
saveSettings(updated); const defaultPath = await fetchDefaultPath();
return updated; const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
} saveSettings(defaultSettings);
return defaultSettings;
export async function resetToDefaultSettings(): Promise<Settings> { }
const defaultPath = await fetchDefaultPath(); export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath }; if (mode === "auto") {
saveSettings(defaultSettings); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return defaultSettings; if (prefersDark) {
} document.documentElement.classList.add("dark");
}
export function applyThemeMode(mode: "auto" | "light" | "dark"): void { else {
if (mode === "auto") { document.documentElement.classList.remove("dark");
// Check system preference }
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; }
if (prefersDark) { else if (mode === "dark") {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
} else { }
document.documentElement.classList.remove("dark"); else {
document.documentElement.classList.remove("dark");
} }
} else if (mode === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
} }
+8 -14
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>(); const spectrumCache = new Map<string, any>();
export function setSpectrumCache(filePath: string, spectrumData: any): void { export function setSpectrumCache(filePath: string, spectrumData: any): void {
spectrumCache.set(filePath, spectrumData); spectrumCache.set(filePath, spectrumData);
} }
export function getSpectrumCache(filePath: string): any | null { export function getSpectrumCache(filePath: string): any | null {
return spectrumCache.get(filePath) || null; return spectrumCache.get(filePath) || null;
} }
export function clearSpectrumCache(filePath?: string): void { export function clearSpectrumCache(filePath?: string): void {
if (filePath) { if (filePath) {
spectrumCache.delete(filePath); spectrumCache.delete(filePath);
} else { }
spectrumCache.clear(); else {
} spectrumCache.clear();
}
} }
+267 -282
View File
@@ -1,290 +1,275 @@
export interface Theme { export interface Theme {
name: string; name: string;
label: string; label: string;
cssVars: {
light: Record<string, string>;
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)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.145 0 0)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.145 0 0)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.97 0 0)",
"muted-foreground": "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
"accent-foreground": "oklch(0.205 0 0)",
destructive: "oklch(0.58 0.22 27)",
border: "oklch(0.922 0 0)",
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)",
card: "oklch(0.205 0 0)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
"popover-foreground": "oklch(0.985 0 0)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
"muted-foreground": "oklch(0.708 0 0)",
accent: "oklch(0.371 0 0)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
};
// Primary colors yang berbeda untuk setiap tema
interface PrimaryColors {
light: {
primary: string;
"primary-foreground": string;
};
dark: {
primary: string;
"primary-foreground": string;
};
}
const primaryColors: Record<string, PrimaryColors> = {
amber: {
light: {
primary: "oklch(0.67 0.16 58)",
"primary-foreground": "oklch(0.99 0.02 95)",
},
dark: {
primary: "oklch(0.77 0.16 70)",
"primary-foreground": "oklch(0.28 0.07 46)",
},
},
blue: {
light: {
primary: "oklch(0.488 0.243 264.376)",
"primary-foreground": "oklch(0.97 0.014 254.604)",
},
dark: {
primary: "oklch(0.42 0.18 266)",
"primary-foreground": "oklch(0.97 0.014 254.604)",
},
},
cyan: {
light: {
primary: "oklch(0.61 0.11 222)",
"primary-foreground": "oklch(0.98 0.02 201)",
},
dark: {
primary: "oklch(0.71 0.13 215)",
"primary-foreground": "oklch(0.30 0.05 230)",
},
},
emerald: {
light: {
primary: "oklch(0.60 0.13 163)",
"primary-foreground": "oklch(0.98 0.02 166)",
},
dark: {
primary: "oklch(0.70 0.15 162)",
"primary-foreground": "oklch(0.26 0.05 173)",
},
},
fuchsia: {
light: {
primary: "oklch(0.59 0.26 323)",
"primary-foreground": "oklch(0.98 0.02 320)",
},
dark: {
primary: "oklch(0.67 0.26 322)",
"primary-foreground": "oklch(0.98 0.02 320)",
},
},
green: {
light: {
primary: "oklch(0.648 0.2 131.684)",
"primary-foreground": "oklch(0.986 0.031 120.757)",
},
dark: {
primary: "oklch(0.648 0.2 131.684)",
"primary-foreground": "oklch(0.986 0.031 120.757)",
},
},
indigo: {
light: {
primary: "oklch(0.51 0.23 277)",
"primary-foreground": "oklch(0.96 0.02 272)",
},
dark: {
primary: "oklch(0.59 0.20 277)",
"primary-foreground": "oklch(0.96 0.02 272)",
},
},
lime: {
light: {
primary: "oklch(0.65 0.18 132)",
"primary-foreground": "oklch(0.99 0.03 121)",
},
dark: {
primary: "oklch(0.77 0.20 131)",
"primary-foreground": "oklch(0.27 0.07 132)",
},
},
neutral: {
light: {
primary: "oklch(0.205 0 0)",
"primary-foreground": "oklch(0.985 0 0)",
},
dark: {
primary: "oklch(0.922 0 0)",
"primary-foreground": "oklch(0.205 0 0)",
},
},
orange: {
light: {
primary: "oklch(0.646 0.222 41.116)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
},
dark: {
primary: "oklch(0.705 0.213 47.604)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
},
},
pink: {
light: {
primary: "oklch(0.59 0.22 1)",
"primary-foreground": "oklch(0.97 0.01 343)",
},
dark: {
primary: "oklch(0.66 0.21 354)",
"primary-foreground": "oklch(0.97 0.01 343)",
},
},
purple: {
light: {
primary: "oklch(0.56 0.25 302)",
"primary-foreground": "oklch(0.98 0.01 308)",
},
dark: {
primary: "oklch(0.63 0.23 304)",
"primary-foreground": "oklch(0.98 0.01 308)",
},
},
red: {
light: {
primary: "oklch(0.577 0.245 27.325)",
"primary-foreground": "oklch(0.971 0.013 17.38)",
},
dark: {
primary: "oklch(0.637 0.237 25.331)",
"primary-foreground": "oklch(0.971 0.013 17.38)",
},
},
rose: {
light: {
primary: "oklch(0.586 0.253 17.585)",
"primary-foreground": "oklch(0.969 0.015 12.422)",
},
dark: {
primary: "oklch(0.645 0.246 16.439)",
"primary-foreground": "oklch(0.969 0.015 12.422)",
},
},
sky: {
light: {
primary: "oklch(0.59 0.14 242)",
"primary-foreground": "oklch(0.98 0.01 237)",
},
dark: {
primary: "oklch(0.68 0.15 237)",
"primary-foreground": "oklch(0.29 0.06 243)",
},
},
teal: {
light: {
primary: "oklch(0.60 0.10 185)",
"primary-foreground": "oklch(0.98 0.01 181)",
},
dark: {
primary: "oklch(0.70 0.12 183)",
"primary-foreground": "oklch(0.28 0.04 193)",
},
},
violet: {
light: {
primary: "oklch(0.541 0.281 293.009)",
"primary-foreground": "oklch(0.969 0.016 293.756)",
},
dark: {
primary: "oklch(0.606 0.25 292.717)",
"primary-foreground": "oklch(0.969 0.016 293.756)",
},
},
yellow: {
light: {
primary: "oklch(0.852 0.199 91.936)",
"primary-foreground": "oklch(0.421 0.095 57.708)",
},
dark: {
primary: "oklch(0.795 0.184 86.047)",
"primary-foreground": "oklch(0.421 0.095 57.708)",
},
},
};
// Helper function untuk menggabungkan base colors dengan primary colors
function createTheme(
name: string,
label: string,
primary: PrimaryColors
): Theme {
return {
name,
label,
cssVars: { cssVars: {
light: { ...baseLightColors, ...primary.light }, light: Record<string, string>;
dark: { ...baseDarkColors, ...primary.dark }, dark: Record<string, string>;
};
}
const baseLightColors: Record<string, string> = {
background: "oklch(1 0 0)",
foreground: "oklch(0.145 0 0)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.145 0 0)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.145 0 0)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.97 0 0)",
"muted-foreground": "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
"accent-foreground": "oklch(0.205 0 0)",
destructive: "oklch(0.58 0.22 27)",
border: "oklch(0.922 0 0)",
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)",
card: "oklch(0.205 0 0)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
"popover-foreground": "oklch(0.985 0 0)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
"muted-foreground": "oklch(0.708 0 0)",
accent: "oklch(0.371 0 0)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
};
interface PrimaryColors {
light: {
primary: string;
"primary-foreground": string;
};
dark: {
primary: string;
"primary-foreground": string;
};
}
const primaryColors: Record<string, PrimaryColors> = {
amber: {
light: {
primary: "oklch(0.67 0.16 58)",
"primary-foreground": "oklch(0.99 0.02 95)",
},
dark: {
primary: "oklch(0.77 0.16 70)",
"primary-foreground": "oklch(0.28 0.07 46)",
},
}, },
}; blue: {
light: {
primary: "oklch(0.488 0.243 264.376)",
"primary-foreground": "oklch(0.97 0.014 254.604)",
},
dark: {
primary: "oklch(0.42 0.18 266)",
"primary-foreground": "oklch(0.97 0.014 254.604)",
},
},
cyan: {
light: {
primary: "oklch(0.61 0.11 222)",
"primary-foreground": "oklch(0.98 0.02 201)",
},
dark: {
primary: "oklch(0.71 0.13 215)",
"primary-foreground": "oklch(0.30 0.05 230)",
},
},
emerald: {
light: {
primary: "oklch(0.60 0.13 163)",
"primary-foreground": "oklch(0.98 0.02 166)",
},
dark: {
primary: "oklch(0.70 0.15 162)",
"primary-foreground": "oklch(0.26 0.05 173)",
},
},
fuchsia: {
light: {
primary: "oklch(0.59 0.26 323)",
"primary-foreground": "oklch(0.98 0.02 320)",
},
dark: {
primary: "oklch(0.67 0.26 322)",
"primary-foreground": "oklch(0.98 0.02 320)",
},
},
green: {
light: {
primary: "oklch(0.648 0.2 131.684)",
"primary-foreground": "oklch(0.986 0.031 120.757)",
},
dark: {
primary: "oklch(0.648 0.2 131.684)",
"primary-foreground": "oklch(0.986 0.031 120.757)",
},
},
indigo: {
light: {
primary: "oklch(0.51 0.23 277)",
"primary-foreground": "oklch(0.96 0.02 272)",
},
dark: {
primary: "oklch(0.59 0.20 277)",
"primary-foreground": "oklch(0.96 0.02 272)",
},
},
lime: {
light: {
primary: "oklch(0.65 0.18 132)",
"primary-foreground": "oklch(0.99 0.03 121)",
},
dark: {
primary: "oklch(0.77 0.20 131)",
"primary-foreground": "oklch(0.27 0.07 132)",
},
},
neutral: {
light: {
primary: "oklch(0.205 0 0)",
"primary-foreground": "oklch(0.985 0 0)",
},
dark: {
primary: "oklch(0.922 0 0)",
"primary-foreground": "oklch(0.205 0 0)",
},
},
orange: {
light: {
primary: "oklch(0.646 0.222 41.116)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
},
dark: {
primary: "oklch(0.705 0.213 47.604)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
},
},
pink: {
light: {
primary: "oklch(0.59 0.22 1)",
"primary-foreground": "oklch(0.97 0.01 343)",
},
dark: {
primary: "oklch(0.66 0.21 354)",
"primary-foreground": "oklch(0.97 0.01 343)",
},
},
purple: {
light: {
primary: "oklch(0.56 0.25 302)",
"primary-foreground": "oklch(0.98 0.01 308)",
},
dark: {
primary: "oklch(0.63 0.23 304)",
"primary-foreground": "oklch(0.98 0.01 308)",
},
},
red: {
light: {
primary: "oklch(0.577 0.245 27.325)",
"primary-foreground": "oklch(0.971 0.013 17.38)",
},
dark: {
primary: "oklch(0.637 0.237 25.331)",
"primary-foreground": "oklch(0.971 0.013 17.38)",
},
},
rose: {
light: {
primary: "oklch(0.586 0.253 17.585)",
"primary-foreground": "oklch(0.969 0.015 12.422)",
},
dark: {
primary: "oklch(0.645 0.246 16.439)",
"primary-foreground": "oklch(0.969 0.015 12.422)",
},
},
sky: {
light: {
primary: "oklch(0.59 0.14 242)",
"primary-foreground": "oklch(0.98 0.01 237)",
},
dark: {
primary: "oklch(0.68 0.15 237)",
"primary-foreground": "oklch(0.29 0.06 243)",
},
},
teal: {
light: {
primary: "oklch(0.60 0.10 185)",
"primary-foreground": "oklch(0.98 0.01 181)",
},
dark: {
primary: "oklch(0.70 0.12 183)",
"primary-foreground": "oklch(0.28 0.04 193)",
},
},
violet: {
light: {
primary: "oklch(0.541 0.281 293.009)",
"primary-foreground": "oklch(0.969 0.016 293.756)",
},
dark: {
primary: "oklch(0.606 0.25 292.717)",
"primary-foreground": "oklch(0.969 0.016 293.756)",
},
},
yellow: {
light: {
primary: "oklch(0.852 0.199 91.936)",
"primary-foreground": "oklch(0.421 0.095 57.708)",
},
dark: {
primary: "oklch(0.795 0.184 86.047)",
"primary-foreground": "oklch(0.421 0.095 57.708)",
},
},
};
function createTheme(name: string, label: string, primary: PrimaryColors): Theme {
return {
name,
label,
cssVars: {
light: { ...baseLightColors, ...primary.light },
dark: { ...baseDarkColors, ...primary.dark },
},
};
} }
export const themes: Theme[] = [ export const themes: Theme[] = [
createTheme("amber", "Amber", primaryColors.amber), createTheme("amber", "Amber", primaryColors.amber),
createTheme("blue", "Blue", primaryColors.blue), createTheme("blue", "Blue", primaryColors.blue),
createTheme("cyan", "Cyan", primaryColors.cyan), createTheme("cyan", "Cyan", primaryColors.cyan),
createTheme("emerald", "Emerald", primaryColors.emerald), createTheme("emerald", "Emerald", primaryColors.emerald),
createTheme("fuchsia", "Fuchsia", primaryColors.fuchsia), createTheme("fuchsia", "Fuchsia", primaryColors.fuchsia),
createTheme("green", "Green", primaryColors.green), createTheme("green", "Green", primaryColors.green),
createTheme("indigo", "Indigo", primaryColors.indigo), createTheme("indigo", "Indigo", primaryColors.indigo),
createTheme("lime", "Lime", primaryColors.lime), createTheme("lime", "Lime", primaryColors.lime),
createTheme("neutral", "Neutral", primaryColors.neutral), createTheme("neutral", "Neutral", primaryColors.neutral),
createTheme("orange", "Orange", primaryColors.orange), createTheme("orange", "Orange", primaryColors.orange),
createTheme("pink", "Pink", primaryColors.pink), createTheme("pink", "Pink", primaryColors.pink),
createTheme("purple", "Purple", primaryColors.purple), createTheme("purple", "Purple", primaryColors.purple),
createTheme("red", "Red", primaryColors.red), createTheme("red", "Red", primaryColors.red),
createTheme("rose", "Rose", primaryColors.rose), createTheme("rose", "Rose", primaryColors.rose),
createTheme("sky", "Sky", primaryColors.sky), createTheme("sky", "Sky", primaryColors.sky),
createTheme("teal", "Teal", primaryColors.teal), createTheme("teal", "Teal", primaryColors.teal),
createTheme("violet", "Violet", primaryColors.violet), createTheme("violet", "Violet", primaryColors.violet),
createTheme("yellow", "Yellow", primaryColors.yellow), createTheme("yellow", "Yellow", primaryColors.yellow),
].sort((a, b) => a.name.localeCompare(b.name)); ].sort((a, b) => a.name.localeCompare(b.name));
export function applyTheme(themeName: string) { export function applyTheme(themeName: string) {
const theme = themes.find((t) => t.name === themeName) || themes[0]; const theme = themes.find((t) => t.name === themeName) || themes[0];
const root = document.documentElement; const root = document.documentElement;
const isDark = root.classList.contains("dark"); const isDark = root.classList.contains("dark");
const vars = isDark ? theme.cssVars.dark : theme.cssVars.light; const vars = isDark ? theme.cssVars.dark : theme.cssVars.light;
Object.entries(vars).forEach(([key, value]) => {
Object.entries(vars).forEach(([key, value]) => { root.style.setProperty(`--${key}`, value);
root.style.setProperty(`--${key}`, value); });
});
} }
+37 -47
View File
@@ -1,55 +1,45 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound, } from "./audio";
playSuccessSound,
playErrorSound,
playWarningSound,
playInfoSound,
} from "./audio";
import { logger } from "./logger"; import { logger } from "./logger";
import { getSettings } from "./settings"; import { getSettings } from "./settings";
const toastStyle = { const toastStyle = {
className: "font-mono lowercase", className: "font-mono lowercase",
}; };
// Helper to check if SFX is enabled
const isSfxEnabled = () => getSettings().sfxEnabled; const isSfxEnabled = () => getSettings().sfxEnabled;
// Wrapper functions for toast with sound effects
export const toastWithSound = { export const toastWithSound = {
success: (message: string, data?: any) => { success: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.success(msg); logger.success(msg);
if (isSfxEnabled()) playSuccessSound(); if (isSfxEnabled())
return toast.success(msg, { ...toastStyle, ...data }); playSuccessSound();
}, return toast.success(msg, { ...toastStyle, ...data });
},
error: (message: string, data?: any) => { error: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.error(msg); logger.error(msg);
if (isSfxEnabled()) playErrorSound(); if (isSfxEnabled())
return toast.error(msg, { ...toastStyle, ...data }); playErrorSound();
}, return toast.error(msg, { ...toastStyle, ...data });
},
warning: (message: string, data?: any) => { warning: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.warning(msg); logger.warning(msg);
if (isSfxEnabled()) playWarningSound(); if (isSfxEnabled())
return toast.warning(msg, { ...toastStyle, ...data }); playWarningSound();
}, return toast.warning(msg, { ...toastStyle, ...data });
},
info: (message: string, data?: any) => { info: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.info(msg); logger.info(msg);
if (isSfxEnabled()) playInfoSound(); if (isSfxEnabled())
return toast.info(msg, { ...toastStyle, ...data }); playInfoSound();
}, return toast.info(msg, { ...toastStyle, ...data });
},
// Default toast without specific type message: (message: string, data?: any) => {
message: (message: string, data?: any) => { const msg = message.toLowerCase();
const msg = message.toLowerCase(); logger.info(msg);
logger.info(msg); if (isSfxEnabled())
if (isSfxEnabled()) playInfoSound(); playInfoSound();
return toast(msg, { ...toastStyle, ...data }); return toast(msg, { ...toastStyle, ...data });
}, },
}; };
+43 -54
View File
@@ -1,59 +1,48 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime" import { BrowserOpenURL } from "../../wailsjs/runtime/runtime";
import type { Settings } from "./settings"; import type { Settings } from "./settings";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
export function sanitizePath(input: string, os: string): string { export function sanitizePath(input: string, os: string): string {
if (os === "Windows") { let sanitized = input.trim();
return input.replace(/[<>:"/\\|?*]/g, "_"); if (os === "Windows") {
} return sanitized.replace(/[<>:"/\\|?*]/g, "_");
// unix-based OS
return input.replace(/\//g, "_");
}
export function joinPath(os: string, ...parts: string[]): string {
const sep = os === "Windows" ? "\\" : "/";
const filtered = parts.filter(Boolean);
if (filtered.length === 0) return "";
const joined = filtered
.map((p, i) => {
// For first part, only remove trailing slashes (preserve leading slash for absolute paths)
if (i === 0) {
return p.replace(/[/\\]+$/g, "");
}
// For other parts, remove both leading and trailing slashes
return p.replace(/^[/\\]+|[/\\]+$/g, "");
})
.filter(Boolean) // Remove empty strings after trimming
.join(sep);
return joined;
}
export function buildOutputPath(settings: Settings, folder?: string) {
const os = settings.operatingSystem;
const base = settings.downloadPath || "";
const sanitized = folder ? sanitizePath(folder, os) : undefined;
return sanitized ? joinPath(os, base, sanitized) : base;
}
export function openExternal(url: string) {
if (!url) return;
try {
BrowserOpenURL(url);
} catch (error) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
} }
} return sanitized.replace(/\//g, "_");
} }
export function joinPath(os: string, ...parts: string[]): string {
const sep = os === "Windows" ? "\\" : "/";
const filtered = parts.filter(Boolean);
if (filtered.length === 0)
return "";
const joined = filtered
.map((p, i) => {
if (i === 0) {
return p.replace(/[/\\]+$/g, "");
}
return p.replace(/^[/\\]+|[/\\]+$/g, "");
})
.filter(Boolean)
.join(sep);
return joined;
}
export function buildOutputPath(settings: Settings, folder?: string) {
const os = settings.operatingSystem;
const base = settings.downloadPath || "";
const sanitized = folder ? sanitizePath(folder, os) : undefined;
return sanitized ? joinPath(os, base, sanitized) : base;
}
export function openExternal(url: string) {
if (!url)
return;
try {
BrowserOpenURL(url);
}
catch (error) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}
}
+3 -6
View File
@@ -3,10 +3,7 @@ import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import App from "./App.tsx"; import App from "./App.tsx";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(<StrictMode>
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App /> <App />
<Toaster position="bottom-left" duration={1000} /> <Toaster position="bottom-left" duration={1000}/>
</StrictMode> </StrictMode>);
);
+236 -204
View File
@@ -1,241 +1,273 @@
export interface ArtistSimple { export interface ArtistSimple {
id: string; id: string;
name: string;
external_urls: string;
}
export interface TrackMetadata {
artists: string;
name: string;
album_name: string;
album_artist?: string;
duration_ms: number;
images: string;
release_date: string;
track_number: number;
total_tracks?: number; // Total tracks in album
disc_number?: number;
external_urls: string;
isrc: string;
album_type?: string;
spotify_id?: string;
album_id?: string;
album_url?: string;
artist_id?: string;
artist_url?: string;
artists_data?: ArtistSimple[];
}
export interface TrackResponse {
track: TrackMetadata;
}
export interface AlbumInfo {
total_tracks: number;
name: string;
release_date: string;
artists: string;
images: string;
batch?: string;
}
export interface AlbumResponse {
album_info: AlbumInfo;
track_list: TrackMetadata[];
}
export interface PlaylistInfo {
tracks: {
total: number;
};
followers: {
total: number;
};
owner: {
display_name: string;
name: string; name: string;
external_urls: string;
}
export interface TrackMetadata {
artists: string;
name: string;
album_name: string;
album_artist?: string;
duration_ms: number;
images: string; images: string;
}; release_date: string;
batch?: string; track_number: number;
total_tracks?: number;
total_discs?: number;
disc_number?: number;
external_urls: string;
isrc: string;
album_type?: string;
spotify_id?: string;
album_id?: string;
album_url?: string;
artist_id?: string;
artist_url?: string;
artists_data?: ArtistSimple[];
copyright?: string;
publisher?: string;
plays?: string;
status?: string;
}
export interface TrackResponse {
track: TrackMetadata;
}
export interface AlbumInfo {
total_tracks: number;
name: string;
release_date: string;
artists: string;
images: string;
batch?: string;
}
export interface AlbumResponse {
album_info: AlbumInfo;
track_list: TrackMetadata[];
}
export interface PlaylistInfo {
tracks: {
total: number;
};
followers: {
total: number;
};
owner: {
display_name: string;
name: string;
images: string;
};
cover?: string;
description?: string;
batch?: string;
} }
export interface PlaylistResponse { export interface PlaylistResponse {
playlist_info: PlaylistInfo; playlist_info: PlaylistInfo;
track_list: TrackMetadata[]; track_list: TrackMetadata[];
} }
export interface ArtistInfo { export interface ArtistInfo {
name: string;
followers: number;
genres: string[];
images: string;
external_urls: string;
discography_type: string;
total_albums: number;
batch?: string;
}
export interface DiscographyAlbum {
id: string;
name: string;
album_type: string;
release_date: string;
total_tracks: number;
artists: string;
images: string;
external_urls: string;
}
export interface ArtistDiscographyResponse {
artist_info: ArtistInfo;
album_list: DiscographyAlbum[];
track_list: TrackMetadata[];
}
export interface ArtistResponse {
artist: {
name: string; name: string;
followers: number; followers: number;
genres: string[]; genres: string[];
images: string; images: string;
header?: string;
gallery?: string[];
external_urls: string; external_urls: string;
popularity: number; discography_type: string;
}; total_albums: number;
biography?: string;
verified?: boolean;
listeners?: number;
rank?: number;
batch?: string;
} }
export interface DiscographyAlbum {
export type SpotifyMetadataResponse = id: string;
| TrackResponse name: string;
| AlbumResponse album_type: string;
| PlaylistResponse release_date: string;
| ArtistDiscographyResponse total_tracks: number;
| ArtistResponse; artists: string;
images: string;
external_urls: string;
}
export interface ArtistDiscographyResponse {
artist_info: ArtistInfo;
album_list: DiscographyAlbum[];
track_list: TrackMetadata[];
}
export interface ArtistResponse {
artist: {
name: string;
followers: number;
genres: string[];
images: string;
external_urls: string;
popularity: number;
};
}
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
export interface DownloadRequest { export interface DownloadRequest {
isrc: string; isrc: string;
service: "tidal" | "qobuz" | "amazon"; service: "tidal" | "qobuz" | "amazon";
query?: string; query?: string;
track_name?: string; track_name?: string;
artist_name?: string; artist_name?: string;
album_name?: string; album_name?: string;
album_artist?: string; album_artist?: string;
release_date?: string; release_date?: string;
cover_url?: string; // Spotify cover URL for embedding cover_url?: string;
api_url?: string; api_url?: string;
output_dir?: string; output_dir?: string;
audio_format?: string; audio_format?: string;
folder_name?: string; folder_name?: string;
filename_format?: string; filename_format?: string;
track_number?: boolean; track_number?: boolean;
position?: number; position?: number;
use_album_track_number?: boolean; use_album_track_number?: boolean;
spotify_id?: string; spotify_id?: string;
embed_lyrics?: boolean; // Whether to embed lyrics into the audio file embed_lyrics?: boolean;
embed_max_quality_cover?: boolean; // Whether to embed max quality cover art embed_max_quality_cover?: boolean;
service_url?: string; service_url?: string;
duration?: number; // Track duration in seconds for better matching duration?: number;
item_id?: string; // Optional queue item ID for multi-service fallback tracking item_id?: string;
spotify_track_number?: number; // Track number from Spotify album spotify_track_number?: number;
spotify_disc_number?: number; // Disc number from Spotify album spotify_disc_number?: number;
spotify_total_tracks?: number; // Total tracks in album from Spotify spotify_total_tracks?: number;
spotify_total_discs?: number;
copyright?: string;
publisher?: string;
spotify_url?: string;
} }
export interface DownloadResponse { export interface DownloadResponse {
success: boolean; success: boolean;
message: string; message: string;
file?: string; file?: string;
error?: string; error?: string;
already_exists?: boolean; already_exists?: boolean;
item_id?: string; // Queue item ID for tracking item_id?: string;
} }
export interface HealthResponse { export interface HealthResponse {
status: string; status: string;
time: string; time: string;
} }
export interface TimeSlice { export interface TimeSlice {
time: number; time: number;
magnitudes: number[]; magnitudes: number[];
} }
export interface SpectrumData { export interface SpectrumData {
time_slices: TimeSlice[]; time_slices: TimeSlice[];
sample_rate: number; sample_rate: number;
freq_bins: number; freq_bins: number;
duration: number; duration: number;
max_freq: number; max_freq: number;
} }
export interface AnalysisResult { export interface AnalysisResult {
file_path: string; file_path: string;
file_size: number; file_size: number;
sample_rate: number; sample_rate: number;
channels: number; channels: number;
bits_per_sample: number; bits_per_sample: number;
total_samples: number; total_samples: number;
duration: number; duration: number;
bit_depth: string; bit_depth: string;
dynamic_range: number; dynamic_range: number;
peak_amplitude: number; peak_amplitude: number;
rms_level: number; rms_level: number;
spectrum?: SpectrumData; spectrum?: SpectrumData;
} }
export interface LyricsDownloadRequest { export interface LyricsDownloadRequest {
spotify_id: string; spotify_id: string;
track_name: string; track_name: string;
artist_name: string; artist_name: string;
output_dir?: string; album_name?: string;
filename_format?: string; album_artist?: string;
track_number?: boolean; release_date?: string;
position?: number; output_dir?: string;
use_album_track_number?: boolean; filename_format?: string;
track_number?: boolean;
position?: number;
use_album_track_number?: boolean;
disc_number?: number;
} }
export interface LyricsDownloadResponse { export interface LyricsDownloadResponse {
success: boolean; success: boolean;
message: string; message: string;
file?: string; file?: string;
error?: string; error?: string;
already_exists?: boolean; already_exists?: boolean;
} }
export interface TrackAvailability { export interface TrackAvailability {
spotify_id: string; spotify_id: string;
tidal: boolean; tidal: boolean;
amazon: boolean; amazon: boolean;
qobuz: boolean; qobuz: boolean;
tidal_url?: string; tidal_url?: string;
amazon_url?: string; amazon_url?: string;
qobuz_url?: string; qobuz_url?: string;
} }
export interface CoverDownloadRequest { export interface CoverDownloadRequest {
cover_url: string; cover_url: string;
track_name: string; track_name: string;
artist_name: string; artist_name: string;
output_dir?: string; album_name?: string;
filename_format?: string; album_artist?: string;
track_number?: boolean; release_date?: string;
position?: number; output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
disc_number?: number;
} }
export interface CoverDownloadResponse { export interface CoverDownloadResponse {
success: boolean; success: boolean;
message: string; message: string;
file?: string; file?: string;
error?: string; error?: string;
already_exists?: boolean; already_exists?: boolean;
}
export interface HeaderDownloadRequest {
header_url: string;
artist_name: string;
output_dir?: string;
}
export interface HeaderDownloadResponse {
success: boolean;
message: string;
file?: string;
error?: string;
already_exists?: boolean;
}
export interface GalleryImageDownloadRequest {
image_url: string;
artist_name: string;
image_index: number;
output_dir?: string;
}
export interface GalleryImageDownloadResponse {
success: boolean;
message: string;
file?: string;
error?: string;
already_exists?: boolean;
}
export interface AvatarDownloadRequest {
avatar_url: string;
artist_name: string;
output_dir?: string;
}
export interface AvatarDownloadResponse {
success: boolean;
message: string;
file?: string;
error?: string;
already_exists?: boolean;
} }
export interface AudioMetadata { export interface AudioMetadata {
title: string; title: string;
artist: string; artist: string;
album: string; album: string;
album_artist: string; album_artist: string;
track_number: number; track_number: number;
disc_number: number; disc_number: number;
year: string; year: string;
} }

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