Compare commits

...

73 Commits

Author SHA1 Message Date
afkarxyz 25233349b9 v7.0.7 2026-01-27 06:16:05 +07:00
afkarxyz e04f6e4fdd .credits 2026-01-15 20:03:48 +07:00
afkarxyz 24bcc56a8f .credits 2026-01-15 19:58:23 +07:00
afkarxyz 45ad82bb66 .credits 2026-01-15 19:15:10 +07:00
afkarxyz 13fcb5787d Create FUNDING.yml 2026-01-15 17:57:32 +07:00
afkarxyz 556e720574 Delete .github/workflows/FUNDING.yml 2026-01-15 17:57:15 +07:00
afkarxyz 791553bdc0 .ko-fi 2026-01-15 17:56:48 +07:00
afkarxyz 9361c608ca v7.0.6 2026-01-15 16:52:50 +07:00
afkarxyz 12729e2ca1 v7.0.6 2026-01-15 15:36:19 +07:00
afkarxyz b620112886 v7.0.1 2026-01-15 15:35:20 +07:00
afkarxyz cc1c80d367 v7.0.6 2026-01-15 15:32:32 +07:00
afkarxyz 63149c91a2 v7.0.6 2026-01-15 14:27:34 +07:00
afkarxyz 1e99d8b5c6 v7.0.6 2026-01-15 13:20:04 +07:00
afkarxyz b160d3c790 v7.0.6 2026-01-15 11:03:27 +07:00
afkarxyz d9cf5a5361 .faq 2026-01-14 10:02:29 +07:00
afkarxyz 4f135f1153 v7.0.5 2026-01-14 08:23:50 +07:00
afkarxyz 4ee252f438 v7.0.5 2026-01-14 07:36:14 +07:00
afkarxyz 2fc08de757 v7.0.5 2026-01-14 06:28:51 +07:00
afkarxyz 6e3ca48d3f v7.0.5 2026-01-13 23:28:06 +07:00
afkarxyz 46a7777698 v7.0.5 2026-01-13 22:45:08 +07:00
afkarxyz 0f2174bf80 v7.0.4 2026-01-11 23:18:18 +07:00
afkarxyz 36fb34dc63 v7.0.3 2026-01-11 22:41:29 +07:00
afkarxyz 7f859db173 v7.0.2 2026-01-11 17:34:12 +07:00
afkarxyz 6e66105481 v7.0.1 2026-01-11 10:42:48 +07:00
afkarxyz 85b542983e v7.0.1 2026-01-11 10:39:37 +07:00
afkarxyz ecc6fd961a v7.0.1 2026-01-11 10:18:51 +07:00
afkarxyz 9260adc2d2 v7.0.1 2026-01-11 08:39:14 +07:00
afkarxyz cb6dfc1638 . 2026-01-11 07:52:33 +07:00
afkarxyz 5dacd70287 . 2026-01-10 12:36:51 +07:00
afkarxyz b163355c50 . 2026-01-09 22:54:57 +07:00
afkarxyz 58495dd9fd . 2026-01-09 22:50:24 +07:00
afkarxyz 1eb8a5ac0c . 2026-01-09 17:39:42 +07:00
afkarxyz 452cd9e118 . 2026-01-09 17:37:16 +07:00
Harley Welsh 1345ac25f4 fix: resolve nested download paths for covers and lyrics (#219)
This commit fixes an issue where cover art and lyrics files were being saved in deeply nested directories (e.g. Album/Artist/Album/file) instead of the correct Artist/Album/file path. It adds an isAlbum flag to the frontend hooks to prevent redundant path construction when downloading in an album context.

Co-authored-by: Harley <git@haileywelsh.me>
2026-01-08 12:34:29 +07:00
SjxSubham ae8b610462 Trim whitespace in sanitizePath function (#215)
Add whitespace trimming to sanitizePath function.
2026-01-08 12:34:08 +07:00
Rin 14297171be Security: Enforce strict validation for FFmpeg binary paths (#214) 2026-01-08 12:33:50 +07:00
afkarxyz 6f6c7563a0 .maintenance 2026-01-08 09:07:18 +07:00
afkarxyz a52c2bb658 .maintenance 2026-01-08 08:23:45 +07:00
afkarxyz 2ce400a5f7 Update README.md 2026-01-01 21:43:46 +07:00
Zarz Eleutherius b8fd2d1762 Update project name and description in README (#217) 2026-01-01 21:24:15 +07:00
afkarxyz d2af0d11df .license 2026-01-01 05:48:29 +07:00
afkarxyz 57640d85d2 v7.0 2025-12-24 09:16:25 +07:00
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
afkarxyz cb2a41d068 v6.9 2025-12-20 10:57:13 +07:00
afkarxyz 820a4a30ab v6.9 2025-12-20 10:43:34 +07:00
afkarxyz c9e49b4b95 v6.9 2025-12-20 10:36:39 +07:00
afkarxyz 0ba9443ef4 v6.9 2025-12-20 07:13:55 +07:00
afkarxyz 7f8c968d6a v6.9 2025-12-20 04:59:07 +07:00
afkarxyz 4fee88329b v6.9 2025-12-20 04:49:58 +07:00
afkarxyz 66c30de2db v6.9 2025-12-19 21:04:12 +07:00
afkarxyz 436feb7f7c v6.9 2025-12-19 16:50:43 +07:00
afkarxyz 7d0fde3acc v6.9 2025-12-19 13:29:28 +07:00
afkarxyz 939883c9cd v6.9 2025-12-19 13:26:19 +07:00
TheLittleDoctor 99f3d59ff1 Added a toggle to choose between using Artist property or AlbumArtist property for folder name (#169)
* Corrected function call to correctly download albums vs playlists

* Added setting to prefer AlbumArtist as folder name.
  - In practice, this prevents albums with multiple artists, featured artists, collaborations, or collections like soundtracks, from being split up
  - This is occasionally desirable behavior, so I added it as a toggle rather than a default behavior
2025-12-19 08:39:03 +07:00
Kyle Rector 965f044e0c Showing singular "song" if playlist or album only has one song (#168) 2025-12-19 08:38:50 +07:00
afkarxyz 6a3bd37eb6 Ko-fi 2025-12-17 04:57:12 +07:00
afkarxyz b85ed89af3 v6.8 2025-12-14 15:35:11 +07:00
afkarxyz 8e78a882a3 v6.8 2025-12-14 12:36:37 +07:00
afkarxyz 237ee777c3 v6.8 2025-12-14 12:22:08 +07:00
afkarxyz b44a9abdd6 v6.8 2025-12-14 07:47:18 +07:00
afkarxyz 6c3fb13b25 v6.8 2025-12-13 18:32:50 +07:00
afkarxyz 9398e496e8 v6.8 2025-12-13 18:31:11 +07:00
afkarxyz a8d6276d00 v6.8 2025-12-13 16:42:29 +07:00
afkarxyz 4d3aac4990 v6.8 2025-12-13 14:28:08 +07:00
afkarxyz 64d7f82e52 v6.8 2025-12-13 13:59:41 +07:00
afkarxyz 910420634c v6.8 2025-12-13 13:36:36 +07:00
afkarxyz 22742f1ddd v6.8 2025-12-13 13:32:09 +07:00
afkarxyz 5c1d6619b5 v6.8 2025-12-13 11:43:17 +07:00
Sepehr Aroofzade 76669f551e Add FLAC lyrics embedding with LRCLIB fallback (#151)
* Update lyrics code

* Refactor DownloadFile to use default HTTP client
2025-12-13 04:09:22 +07:00
enriqueqs ffd4daf031 fixed path issue (#157)
Co-authored-by: afkarxyz <mzamzamafkarhadiq@gmail.com>
2025-12-12 17:31:13 +07:00
Heggo 64b86b65a1 Fix Linux Download Path (#153)
* Fix Unknown being appended at the beginning of Download Path on Unix/Linux systems

* Remove extra blank line in backend/filename.go
2025-12-12 17:27:09 +07:00
138 changed files with 19554 additions and 12814 deletions
+2
View File
@@ -0,0 +1,2 @@
github: afkarxyz
ko_fi: afkarxyz
+14 -3
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
env:
GO_VERSION: '1.25.4'
GO_VERSION: '1.25.5'
NODE_VERSION: '24'
jobs:
@@ -243,10 +243,21 @@ jobs:
run: |
upx --best --lzma build/bin/SpotiFLAC
- name: Cache appimagetool
id: cache-appimagetool
uses: actions/cache@v4
with:
path: appimagetool
key: appimagetool-x86_64-v1
- name: Download appimagetool
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
run: |
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
- name: Make appimagetool executable
run: chmod +x appimagetool
- name: Create AppImage
run: |
+1 -1
View File
@@ -61,4 +61,4 @@ test
# Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md
build.txt
push.bat
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 afkarxyz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+76 -11
View File
@@ -1,37 +1,102 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
<!-- ![Maintenance](https://maintenance.afkarxyz.fun?v=3) -->
![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<div align="center">
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=)
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-Any-FCC624?style=for-the-badge&logo=linux&logoColor=white)
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
## Screenshot
![Image](https://github.com/user-attachments/assets/4da39b61-dcf8-4018-83e4-b8750a671245)
![Image](https://github.com/user-attachments/assets/eba25a4a-0eb9-4d88-9646-80c7658a7de6)
## Lossless Audio Checker
## Other projects
A simple utility for verifying the authenticity of FLAC files.
### [SpotiFLAC Next](https://github.com/spotiverse/SpotiFLAC-Next)
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
Get Spotify tracks in Hi-Res lossless FLACs — no account required.
#
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a)
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
### [SpotubeDL](https://spotubedl.com)
## Other project
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
## FAQ
### Is this software free?
_Yes. This software is completely free.
You do not need an account, login, or subscription.
All you need is an internet connection._
### Can using this software get my Spotify account suspended or banned?
_No.
This software has no connection to your Spotify account.
Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication._
### Where does the audio come from?
_The audio is fetched using third-party APIs._
### Why does metadata fetching sometimes fail?
_This usually happens because your IP address has been rate-limited.
You can wait and try again later, or use a VPN to bypass the rate limit._
### Why does Windows Defender or antivirus flag or delete the file?
_This is a false positive.
It likely happens because the executable is compressed using UPX._
_If you are concerned, you can fork the repository and build the software yourself from source._
### Want to support the project?
_If this software is useful and brings you value,
consider supporting the project by buying me a coffee.
Your support helps keep development going._
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz)
## 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.
## API Credits
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api)
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev/)
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
+825 -126
View File
File diff suppressed because it is too large Load Diff
+171 -301
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
@@ -16,11 +15,8 @@ import (
)
type AmazonDownloader struct {
client *http.Client
regions []string
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
client *http.Client
regions []string
}
type SongLinkResponse struct {
@@ -29,19 +25,13 @@ type SongLinkResponse struct {
} `json:"linksByPlatform"`
}
type DoubleDoubleSubmitResponse struct {
Success bool `json:"success"`
ID string `json:"id"`
}
type DoubleDoubleStatusResponse struct {
Status string `json:"status"`
FriendlyStatus string `json:"friendlyStatus"`
URL string `json:"url"`
Current struct {
Name string `json:"name"`
Artist string `json:"artist"`
} `json:"current"`
type AfkarXYZResponse struct {
Success bool `json:"success"`
Data struct {
DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
} `json:"data"`
}
func NewAmazonDownloader() *AmazonDownloader {
@@ -49,102 +39,52 @@ func NewAmazonDownloader() *AmazonDownloader {
client: &http.Client{
Timeout: 120 * time.Second,
},
regions: []string{"us", "eu"},
apiCallResetTime: time.Now(),
regions: []string{"us", "eu"},
}
}
func (a *AmazonDownloader) getRandomUserAgent() string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
rand.Intn(4)+11, rand.Intn(5)+4,
rand.Intn(7)+530, rand.Intn(7)+30,
rand.Intn(25)+80, rand.Intn(1500)+3000, rand.Intn(65)+60,
rand.Intn(7)+530, rand.Intn(6)+30)
}
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
// Reset counter every minute
now := time.Now()
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
a.apiCallCount = 0
a.apiCallResetTime = time.Now()
}
}
spotifyBase := "https://open.spotify.com/track/"
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
// Add delay between requests (6 seconds = 10 requests per minute)
if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second // 7 seconds to be safe
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
// Decode base64 API URL
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))
apiBase := "https://api.song.link/v1-alpha.1/links?url="
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", a.getRandomUserAgent())
fmt.Println("Getting Amazon URL...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = a.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
// Update rate limit tracking
a.lastAPICallTime = time.Now()
a.apiCallCount++
if resp.StatusCode == 429 { // Too Many Requests
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
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("API returned empty response")
}
var songLinkResp SongLinkResponse
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
@@ -154,7 +94,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if strings.Contains(amazonURL, "trackAsin=") {
parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 {
@@ -168,206 +107,85 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return amazonURL, nil
}
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) {
var lastError error
for _, region := range a.regions {
fmt.Printf("\nTrying region: %s...\n", region)
// Decode base64 service URL
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
// Step 1: Submit download request
encodedURL := url.QueryEscape(amazonURL)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
req, err := http.NewRequest("GET", submitURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create request: %w", err)
continue
}
req.Header.Set("User-Agent", a.getRandomUserAgent())
fmt.Println("Submitting download request...")
resp, err := a.client.Do(req)
if err != nil {
lastError = fmt.Errorf("failed to submit request: %w", err)
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
continue
}
var submitResp DoubleDoubleSubmitResponse
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
resp.Body.Close()
lastError = fmt.Errorf("failed to decode submit response: %w", err)
continue
}
resp.Body.Close()
if !submitResp.Success || submitResp.ID == "" {
lastError = fmt.Errorf("submit request failed")
continue
}
downloadID := submitResp.ID
fmt.Printf("Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("Waiting for download to complete...")
maxWait := 300 * time.Second
elapsed := time.Duration(0)
pollInterval := 3 * time.Second
for elapsed < maxWait {
time.Sleep(pollInterval)
elapsed += pollInterval
statusReq, err := http.NewRequest("GET", statusURL, nil)
if err != nil {
continue
}
statusReq.Header.Set("User-Agent", a.getRandomUserAgent())
statusResp, err := a.client.Do(statusReq)
if err != nil {
fmt.Printf("\rStatus check failed, retrying...")
continue
}
if statusResp.StatusCode != 200 {
statusResp.Body.Close()
fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode)
continue
}
var status DoubleDoubleStatusResponse
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
statusResp.Body.Close()
fmt.Printf("\rInvalid JSON response, retrying...")
continue
}
statusResp.Body.Close()
if status.Status == "done" {
fmt.Println("\nDownload ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
} else if strings.HasPrefix(fileURL, "/") {
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
}
trackName := status.Current.Name
artist := status.Current.Artist
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
// Download file
downloadReq, err := http.NewRequest("GET", fileURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create download request: %w", err)
break
}
downloadReq.Header.Set("User-Agent", a.getRandomUserAgent())
fileResp, err := a.client.Do(downloadReq)
if err != nil {
lastError = fmt.Errorf("failed to download file: %w", err)
break
}
defer fileResp.Body.Close()
if fileResp.StatusCode != 200 {
lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode)
break
}
// Generate filename
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
for _, char := range `<>:"/\|?*` {
fileName = strings.ReplaceAll(fileName, string(char), "")
}
fileName = strings.TrimSpace(fileName)
filePath := filepath.Join(outputDir, fileName)
// Save file
out, err := os.Create(filePath)
if err != nil {
lastError = fmt.Errorf("failed to create file: %w", err)
break
}
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, fileResp.Body)
if err != nil {
out.Close()
return "", fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete!")
return filePath, nil
} else if status.Status == "error" {
errorMsg := status.FriendlyStatus
if errorMsg == "" {
errorMsg = "Unknown error"
}
lastError = fmt.Errorf("processing failed: %s", errorMsg)
break
} else {
// Still processing
friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" {
friendlyStatus = status.Status
}
fmt.Printf("\r%s...", friendlyStatus)
}
}
if elapsed >= maxWait {
lastError = fmt.Errorf("download timeout")
fmt.Printf("\nError with %s region: %v\n", region, lastError)
continue
}
if lastError != nil {
fmt.Printf("\nError with %s region: %v\n", region, lastError)
}
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", err
}
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
fmt.Printf("Fetching from AfkarXYZ...\n")
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
}
bodyBytes, _ := io.ReadAll(resp.Body)
var apiResp AfkarXYZResponse
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if !apiResp.Success || apiResp.Data.DirectLink == "" {
return "", fmt.Errorf("AfkarXYZ failed or no link found")
}
downloadURL := apiResp.Data.DirectLink
fileName := apiResp.Data.FileName
if fileName == "" {
fileName = "track.flac"
}
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath)
if err != nil {
return "", err
}
defer out.Close()
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
dlResp, err := a.client.Do(dlReq)
if err != nil {
return "", err
}
defer dlResp.Body.Close()
fmt.Printf("Downloading from AfkarXYZ: %s\n", fileName)
pw := NewProgressWriter(out)
_, err = io.Copy(pw, dlResp.Body)
if err != nil {
out.Close()
os.Remove(filePath)
return "", err
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return filePath, nil
}
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Create output directory if needed
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
}
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
@@ -378,47 +196,57 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
// Download from service
filePath, err := a.DownloadFromService(amazonURL, outputDir)
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
if err != nil {
return "", err
}
// File already has embedded metadata, just rename if needed
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
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
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
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 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default: // "title-artist"
default:
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
}
@@ -427,7 +255,6 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
newFilename = newFilename + ".flac"
newFilePath := filepath.Join(outputDir, newFilename)
// Rename file
if err := os.Rename(filePath, newFilePath); err != nil {
fmt.Printf("Warning: Failed to rename file: %v\n", err)
} else {
@@ -436,17 +263,60 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
}
}
fmt.Println("Embedding Spotify metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = filePath + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: spotifyTrackName,
Artist: spotifyArtistName,
Album: spotifyAlbumName,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Println("Metadata embedded successfully")
}
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Amazon Music")
return filePath, nil
}
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Amazon URL from Spotify track ID
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
}
+55 -29
View File
@@ -9,9 +9,9 @@ import (
mewflac "github.com/mewkiz/flac"
)
// AnalysisResult contains the audio analysis data
type AnalysisResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
SampleRate uint32 `json:"sample_rate"`
Channels uint8 `json:"channels"`
BitsPerSample uint8 `json:"bits_per_sample"`
@@ -24,13 +24,16 @@ type AnalysisResult struct {
Spectrum *SpectrumData `json:"spectrum,omitempty"`
}
// AnalyzeTrack performs audio analysis on a FLAC file
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
// Parse FLAC file
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
@@ -38,70 +41,58 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
}
// Extract basic audio properties from STREAMINFO block
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
// Read STREAMINFO data
data := streamInfo.Data
if len(data) >= 18 {
// Sample rate (bits 10-29 of bytes 10-13)
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
// Channels (bits 30-32 of byte 12)
result.Channels = ((data[12] >> 1) & 0x07) + 1
// Bits per sample (bits 33-37 of bytes 12-13)
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
// Total samples (bits 38-73 of bytes 13-17)
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
// Calculate duration
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
// Read min/max frame size and block size for additional analysis
// Min block size (bytes 0-1)
// Max block size (bytes 2-3)
// These can give us hints about encoding quality
}
}
}
// Analyze spectrum and calculate real audio metrics
spectrum, err := AnalyzeSpectrum(filepath)
if err != nil {
// Log error but continue
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
} else {
result.Spectrum = spectrum
// Calculate dynamic range, peak, and RMS from decoded samples
calculateRealAudioMetrics(result, filepath)
}
// Set bit depth
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
// Decode FLAC to get actual samples
samples, err := decodeFLACForMetrics(filepath)
if err != nil {
return
}
// Calculate peak amplitude
var peak float64
var sumSquares float64
@@ -116,20 +107,16 @@ func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
sumSquares += sample * sample
}
// Convert peak to dB (reference: 1.0 = 0 dBFS)
peakDB := 20.0 * math.Log10(peak)
result.PeakAmplitude = peakDB
// Calculate RMS (Root Mean Square)
rms := math.Sqrt(sumSquares / float64(len(samples)))
rmsDB := 20.0 * math.Log10(rms)
result.RMSLevel = rmsDB
// Dynamic range is the difference between peak and RMS
result.DynamicRange = peakDB - rmsDB
}
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
func decodeFLACForMetrics(filepath string) ([]float64, error) {
stream, err := mewflac.ParseFile(filepath)
if err != nil {
@@ -137,24 +124,20 @@ func decodeFLACForMetrics(filepath string) ([]float64, error) {
}
defer stream.Close()
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
maxSamples := 10000000
samples := make([]float64, 0, maxSamples)
// Read all audio frames
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
// Get samples from first channel (mono or left channel)
var channelSamples []int32
if len(frame.Subframes) > 0 {
channelSamples = frame.Subframes[0].Samples
}
// Normalize samples to -1.0 to 1.0 range
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
for _, sample := range channelSamples {
if len(samples) >= maxSamples {
@@ -179,3 +162,46 @@ func GetFileSize(filepath string) (int64, error) {
}
return info.Size(), nil
}
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
}
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
}
}
}
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
+2 -3
View File
@@ -6,13 +6,12 @@ import (
)
func GetDefaultMusicPath() string {
// Get user's home directory
homeDir, err := os.UserHomeDir()
if err != nil {
// Fallback to Public Music if can't get home dir
return "C:\\Users\\Public\\Music"
}
// Return path to user's Music folder
return filepath.Join(homeDir, "Music")
}
+370 -41
View File
@@ -12,23 +12,25 @@ import (
)
const (
// Spotify image size codes
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySizeMax = "ab67616d000082c1" // Max resolution
spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1"
)
// CoverDownloadRequest represents a request to download cover art
type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
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"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
DiscNumber int `json:"disc_number"`
}
// CoverDownloadResponse represents the response from cover download
type CoverDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
@@ -37,77 +39,136 @@ type CoverDownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
}
// CoverClient handles cover art downloading
type HeaderDownloadRequest struct {
HeaderURL string `json:"header_url"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
}
type HeaderDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
type CoverClient struct {
httpClient *http.Client
}
// NewCoverClient creates a new cover client
func NewCoverClient() *CoverClient {
return &CoverClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// buildCoverFilename builds the cover filename based on settings (same as track filename)
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
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 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title-artist":
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
filename = fmt.Sprintf("%02d - %s", position, filename)
}
}
return filename + ".jpg"
return filename + ".cover.jpg"
}
// getMaxResolutionURL converts a Spotify cover URL to max resolution
// Falls back to original URL if max resolution is not available
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
// Try to convert to max resolution
if strings.Contains(coverURL, spotifySize640) {
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
// Check if max resolution URL is available
resp, err := c.httpClient.Head(maxURL)
if err == nil && resp.StatusCode == http.StatusOK {
return maxURL
}
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
}
// Return original URL as fallback
return coverURL
return imageURL
}
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
mediumURL := convertSmallToMedium(imageURL)
if strings.Contains(mediumURL, spotifySize640) {
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
}
return mediumURL
}
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
if coverURL == "" {
return fmt.Errorf("cover URL is required")
}
downloadURL := convertSmallToMedium(coverURL)
if embedMaxQualityCover {
downloadURL = c.getMaxResolutionURL(downloadURL)
}
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("failed to download cover: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
}
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %v", err)
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write cover file: %v", err)
}
return nil
}
// DownloadCover downloads cover art for a single track
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
if req.CoverURL == "" {
return &CoverDownloadResponse{
@@ -116,12 +177,11 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, fmt.Errorf("cover URL is required")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = SanitizeFolderPath(outputDir)
outputDir = NormalizePath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -131,15 +191,13 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
filenameFormat = "title-artist"
}
filename := buildCoverFilename(req.TrackName, req.ArtistName, 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)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &CoverDownloadResponse{
Success: true,
@@ -149,10 +207,8 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, nil
}
// Try to get max resolution URL, fallback to original
downloadURL := c.getMaxResolutionURL(req.CoverURL)
// Download cover image
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return &CoverDownloadResponse{
@@ -169,7 +225,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Create file
file, err := os.Create(filePath)
if err != nil {
return &CoverDownloadResponse{
@@ -179,7 +234,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
}
defer file.Close()
// Write content to file
_, err = io.Copy(file, resp.Body)
if err != nil {
return &CoverDownloadResponse{
@@ -194,3 +248,278 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
File: filePath,
}, nil
}
func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) {
if req.HeaderURL == "" {
return &HeaderDownloadResponse{
Success: false,
Error: "Header URL is required",
}, fmt.Errorf("header URL is required")
}
if req.ArtistName == "" {
return &HeaderDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + "_Header.jpg"
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &HeaderDownloadResponse{
Success: true,
Message: "Header file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.HeaderURL)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download header: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &HeaderDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write header file: %v", err),
}, err
}
return &HeaderDownloadResponse{
Success: true,
Message: "Header downloaded successfully",
File: filePath,
}, nil
}
type GalleryImageDownloadRequest struct {
ImageURL string `json:"image_url"`
ArtistName string `json:"artist_name"`
ImageIndex int `json:"image_index"`
OutputDir string `json:"output_dir"`
}
type GalleryImageDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) {
if req.ImageURL == "" {
return &GalleryImageDownloadResponse{
Success: false,
Error: "Image URL is required",
}, fmt.Errorf("image URL is required")
}
if req.ArtistName == "" {
return &GalleryImageDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1)
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &GalleryImageDownloadResponse{
Success: true,
Message: "Gallery image file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.ImageURL)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download gallery image: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &GalleryImageDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write gallery image file: %v", err),
}, err
}
return &GalleryImageDownloadResponse{
Success: true,
Message: "Gallery image downloaded successfully",
File: filePath,
}, nil
}
type AvatarDownloadRequest struct {
AvatarURL string `json:"avatar_url"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
}
type AvatarDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
}
func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) {
if req.AvatarURL == "" {
return &AvatarDownloadResponse{
Success: false,
Error: "Avatar URL is required",
}, fmt.Errorf("avatar URL is required")
}
if req.ArtistName == "" {
return &AvatarDownloadResponse{
Success: false,
Error: "Artist name is required",
}, fmt.Errorf("artist name is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
if err := os.MkdirAll(artistFolder, 0755); err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create artist folder: %v", err),
}, err
}
filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg"
filePath := filepath.Join(artistFolder, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &AvatarDownloadResponse{
Success: true,
Message: "Avatar file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
resp, err := c.httpClient.Get(req.AvatarURL)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download avatar: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create file: %v", err),
}, err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return &AvatarDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write avatar file: %v", err),
}, err
}
return &AvatarDownloadResponse{
Success: true,
Message: "Avatar downloaded successfully",
File: filePath,
}, nil
}
-399
View File
@@ -1,399 +0,0 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
type DeezerDownloader struct {
client *http.Client
}
type DeezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
TitleShort string `json:"title_short"`
Duration int `json:"duration"`
TrackPos int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
ReleaseDate string `json:"release_date"`
Artist struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"artist"`
Album struct {
Title string `json:"title"`
ID int64 `json:"id"`
CoverXL string `json:"cover_xl"`
CoverBig string `json:"cover_big"`
} `json:"album"`
Contributors []struct {
Name string `json:"name"`
Role string `json:"role"`
} `json:"contributors"`
}
type DeezMateResponse struct {
Success bool `json:"success"`
Links struct {
FLAC string `json:"flac"`
} `json:"links"`
}
func NewDeezerDownloader() *DeezerDownloader {
return &DeezerDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
// Decode base64 API URL
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), 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...")
resp, err := d.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
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 (d *DeezerDownloader) GetTrackIDFromURL(deezerURL string) (int64, error) {
// Extract track ID from Deezer URL
// Format: https://www.deezer.com/track/3412534581
parts := strings.Split(deezerURL, "/track/")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid Deezer URL format")
}
// Get the track ID part and remove any query parameters
trackIDStr := strings.Split(parts[1], "?")[0]
trackIDStr = strings.TrimSpace(trackIDStr)
var trackID int64
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
if err != nil {
return 0, fmt.Errorf("failed to parse track ID: %w", err)
}
return trackID, nil
}
func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2sv")
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
resp, err := d.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch track: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var track DeezerTrack
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if track.ID == 0 {
return nil, fmt.Errorf("track not found")
}
return &track, nil
}
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==")
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
resp, err := d.client.Get(url)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
var apiResp DeezMateResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return "", fmt.Errorf("failed to decode API response: %w", err)
}
if !apiResp.Success || apiResp.Links.FLAC == "" {
return "", fmt.Errorf("no FLAC download link available")
}
return apiResp.Links.FLAC, nil
}
func (d *DeezerDownloader) DownloadFile(url, filepath string) error {
resp, err := d.client.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
resp, err := d.client.Get(coverURL)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create cover file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Determine track number to use
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
// Check if format is a template (contains {})
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist)
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
}
return filename + ".flac"
}
func (d *DeezerDownloader) DownloadByURL(deezerURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
fmt.Printf("Using Deezer URL: %s\n", deezerURL)
// Extract track ID from URL
trackID, err := d.GetTrackIDFromURL(deezerURL)
if err != nil {
return "", err
}
// Get track info by ID
track, err := d.GetTrackByID(trackID)
if err != nil {
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Deezer metadata
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Artist.Name
if len(track.Contributors) > 0 {
var mainArtists []string
for _, contrib := range track.Contributors {
if contrib.Role == "Main" {
mainArtists = append(mainArtists, contrib.Name)
}
}
if len(mainArtists) > 0 {
artists = strings.Join(mainArtists, ", ")
}
}
}
if trackTitle == "" {
trackTitle = track.Title
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
downloadURL, err := d.GetDownloadURL(track.ID)
if err != nil {
return "", err
}
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + filepath, nil
}
fmt.Println("Downloading FLAC file...")
if err := d.DownloadFile(downloadURL, filepath); err != nil {
return "", err
}
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.CoverXL != "" {
coverPath = filepath + ".cover.jpg"
fmt.Println("Downloading cover art...")
if err := d.DownloadCoverArt(track.Album.CoverXL, coverPath); err != nil {
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
} else {
defer os.Remove(coverPath)
}
}
fmt.Println("Embedding metadata and cover art...")
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && track.TrackPos > 0 {
trackNumberToEmbed = track.TrackPos
} else {
trackNumberToEmbed = position
}
}
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: track.ReleaseDate,
TrackNumber: trackNumberToEmbed,
DiscNumber: track.DiskNumber,
ISRC: track.ISRC,
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
fmt.Println("Metadata embedded successfully!")
fmt.Println("✓ Downloaded successfully from Deezer")
return filepath, nil
}
func (d *DeezerDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Get Deezer URL from Spotify track ID
deezerURL, err := d.GetDeezerURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return d.DownloadByURL(deezerURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
}
+680
View File
@@ -0,0 +1,680 @@
package backend
import (
"archive/tar"
"archive/zip"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/ulikunitz/xz"
)
func decodeBase64(encoded string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
return string(decoded), nil
}
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
if !filepath.IsAbs(cleanedPath) {
return fmt.Errorf("path must be absolute: %s", path)
}
info, err := os.Stat(cleanedPath)
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory: %s", path)
}
if runtime.GOOS != "windows" {
if info.Mode()&0111 == 0 {
return fmt.Errorf("file is not executable: %s", path)
}
}
base := filepath.Base(cleanedPath)
validNames := map[string]bool{
"ffmpeg": true,
"ffmpeg.exe": true,
"ffprobe": true,
"ffprobe.exe": true,
}
if !validNames[base] {
return fmt.Errorf("invalid executable name: %s", base)
}
return nil
}
const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".spotiflac"), nil
}
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
return filepath.Join(ffmpegDir, ffmpegName), nil
}
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(ffprobePath); err == nil {
return ffprobePath, nil
}
return "", fmt.Errorf("ffprobe not found in app directory")
}
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
if err := ValidateExecutable(ffprobePath); err != nil {
return false, nil
}
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return false, err
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return false, nil
}
cmd := exec.Command(ffmpegPath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0)
SetDownloadSpeed(0)
SetDownloading(true)
defer SetDownloading(false)
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return err
}
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
if runtime.GOOS == "darwin" {
ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled {
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
return err
}
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
} else if !ffmpegInstalled {
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
} else if !ffprobeInstalled {
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
}
return nil
}
var encodedURL string
switch runtime.GOOS {
case "windows":
encodedURL = ffmpegWindowsURL
case "linux":
encodedURL = ffmpegLinuxURL
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
url, err := decodeBase64(encodedURL)
if err != nil {
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
}
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
return nil
}
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download: HTTP %d", resp.StatusCode)
}
totalSize := resp.ContentLength
var downloaded int64
lastTime := time.Now()
var lastBytes int64
if totalSize > 0 {
totalSizeMB := float64(totalSize) / (1024 * 1024)
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
} else {
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
}
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, writeErr := tmpFile.Write(buf[:n])
if writeErr != nil {
return fmt.Errorf("failed to write to temp file: %w", writeErr)
}
downloaded += int64(n)
mbDownloaded := float64(downloaded) / (1024 * 1024)
now := time.Now()
timeDiff := now.Sub(lastTime).Seconds()
var speedMBps float64
if timeDiff > 0.1 {
bytesDiff := float64(downloaded - lastBytes)
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
lastTime = now
lastBytes = downloaded
}
SetDownloadProgress(mbDownloaded)
if speedMBps > 0 {
SetDownloadSpeed(speedMBps)
}
if totalSize > 0 && progressCallback != nil {
rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress)
}
if totalSize > 0 {
percent := float64(downloaded) * 100 / float64(totalSize)
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
mbDownloaded, float64(totalSize)/(1024*1024), percent)
}
} else {
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
}
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
}
tmpFile.Close()
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")
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
return extractTarXz(tmpFile.Name(), destDir)
}
return extractZip(tmpFile.Name(), destDir)
}
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer r.Close()
ffmpegName := "ffmpeg"
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
ffprobeName = "ffprobe.exe"
}
foundFFmpeg := false
foundFFprobe := false
for _, f := range r.File {
baseName := filepath.Base(f.Name)
if f.FileInfo().IsDir() {
continue
}
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
continue
}
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open file in zip: %w", err)
}
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create output file: %w", err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
func extractTarXz(tarXzPath, destDir string) error {
file, err := os.Open(tarXzPath)
if err != nil {
return fmt.Errorf("failed to open tar.xz: %w", err)
}
defer file.Close()
xzReader, err := xz.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create xz reader: %w", err)
}
tarReader := tar.NewReader(xzReader)
ffmpegName := "ffmpeg"
ffprobeName := "ffprobe"
foundFFmpeg := false
foundFFprobe := false
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
baseName := filepath.Base(header.Name)
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
continue
}
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
_, err = io.Copy(outFile, tarReader)
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"`
Bitrate string `json:"bitrate"`
Codec string `json:"codec"`
}
type ConvertAudioResult struct {
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
}
installed, err := IsFFmpegInstalled()
if err != nil || !installed {
return nil, fmt.Errorf("ffmpeg is not installed")
}
results := make([]ConvertAudioResult, len(req.InputFiles))
var wg sync.WaitGroup
var mu sync.Mutex
for i, inputFile := range req.InputFiles {
wg.Add(1)
go func(idx int, inputFile string) {
defer wg.Done()
result := ConvertAudioResult{
InputFile: inputFile,
}
inputExt := strings.ToLower(filepath.Ext(inputFile))
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
inputDir := filepath.Dir(inputFile)
outputFormatUpper := strings.ToUpper(req.OutputFormat)
outputDir := filepath.Join(inputDir, outputFormatUpper)
if err := os.MkdirAll(outputDir, 0755); err != nil {
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
outputExt := "." + strings.ToLower(req.OutputFormat)
outputFile := filepath.Join(outputDir, baseName+outputExt)
if inputExt == outputExt {
result.Error = "Input and output formats are the same"
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
result.OutputFile = outputFile
var coverArtPath string
var lyrics string
var inputMetadata Metadata
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
}
coverArtPath, _ = ExtractCoverArt(inputFile)
lyrics, err = ExtractLyrics(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
} else if lyrics != "" {
fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics))
} else {
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
}
inputMetadata.Lyrics = lyrics
args := []string{
"-i", inputFile,
"-y",
}
switch req.OutputFormat {
case "mp3":
args = append(args,
"-codec:a", "libmp3lame",
"-b:a", req.Bitrate,
"-map", "0:a",
"-id3v2_version", "3",
)
case "m4a":
codec := req.Codec
if codec == "" {
codec = "aac"
}
if codec == "alac" {
args = append(args,
"-codec:a", "alac",
"-map", "0:a",
)
} else {
args = append(args,
"-codec:a", "aac",
"-b:a", req.Bitrate,
"-map", "0:a",
)
}
}
args = append(args, outputFile)
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output))
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
if coverArtPath != "" {
os.Remove(coverArtPath)
}
return
}
if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
}
if lyrics != "" {
if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Lyrics embedded successfully\n")
}
}
if coverArtPath != "" {
os.Remove(coverArtPath)
}
result.Success = true
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
mu.Lock()
results[idx] = result
mu.Unlock()
}(i, inputFile)
}
wg.Wait()
return results, nil
}
type AudioFileInfo struct {
Path string `json:"path"`
Filename string `json:"filename"`
Format string `json:"format"`
Size int64 `json:"size"`
}
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
info, err := os.Stat(filePath)
if err != nil {
return nil, err
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
return &AudioFileInfo{
Path: filePath,
Filename: filepath.Base(filePath),
Format: ext,
Size: info.Size(),
}, nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
// +build !windows
package backend
import (
"os/exec"
)
func setHideWindow(cmd *exec.Cmd) {
}
+15
View File
@@ -0,0 +1,15 @@
//go:build windows
// +build windows
package backend
import (
"os/exec"
"syscall"
)
func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}
+49
View File
@@ -0,0 +1,49 @@
package backend
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Audio Files",
Filters: []runtime.FileFilter{
{
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
Pattern: "*.mp3;*.m4a;*.flac",
},
{
DisplayName: "MP3 Files (*.mp3)",
Pattern: "*.mp3",
},
{
DisplayName: "M4A Files (*.m4a)",
Pattern: "*.m4a",
},
{
DisplayName: "FLAC Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
if err != nil {
return nil, err
}
return files, nil
}
func SelectOutputDirectory(ctx context.Context) (string, error) {
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Output Directory",
})
if err != nil {
return "", err
}
return dir, nil
}
+467
View File
@@ -0,0 +1,467 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
)
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Children []FileInfo `json:"children,omitempty"`
}
type AudioMetadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
Year string `json:"year"`
}
type RenamePreview struct {
OldPath string `json:"old_path"`
OldName string `json:"old_name"`
NewName string `json:"new_name"`
NewPath string `json:"new_path"`
Error string `json:"error,omitempty"`
Metadata AudioMetadata `json:"metadata"`
}
type RenameResult struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func ListDirectory(dirPath string) ([]FileInfo, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var result []FileInfo
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
fileInfo := FileInfo{
Name: entry.Name(),
Path: filepath.Join(dirPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
}
if entry.IsDir() {
children, err := ListDirectory(fileInfo.Path)
if err == nil {
fileInfo.Children = children
}
}
result = append(result, fileInfo)
}
return result, nil
}
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
var result []FileInfo
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
result = append(result, FileInfo{
Name: info.Name(),
Path: path,
IsDir: false,
Size: info.Size(),
})
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return result, nil
}
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
}
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".flac":
return readFlacMetadata(filePath)
case ".mp3":
return readMp3Metadata(filePath)
case ".m4a":
return readM4aMetadata(filePath)
default:
return nil, fmt.Errorf("unsupported file format: %s", ext)
}
}
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
metadata := &AudioMetadata{}
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) != 2 {
continue
}
fieldName := strings.ToUpper(parts[0])
value := parts[1]
switch fieldName {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
case "ALBUM":
metadata.Album = value
case "ALBUMARTIST":
metadata.AlbumArtist = value
case "TRACKNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.TrackNumber = num
}
case "DISCNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.DiscNumber = num
}
case "DATE", "YEAR":
metadata.Year = value
}
}
}
}
return metadata, nil
}
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
metadata := &AudioMetadata{
Title: tag.Title(),
Artist: tag.Artist(),
Album: tag.Album(),
Year: tag.Year(),
}
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
metadata.AlbumArtist = textFrame.Text
}
}
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
trackStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
}
}
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
discStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
}
}
return metadata, nil
}
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return nil, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return nil, 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 nil, 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 nil, err
}
metadata := &AudioMetadata{}
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 "track":
trackStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
case "disc":
discStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
case "date", "year":
if metadata.Year == "" || len(value) > len(metadata.Year) {
metadata.Year = value
}
}
}
return metadata, nil
}
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
metadata, err := readMetadataWithFFprobe(filePath)
if err != nil {
return &AudioMetadata{}, nil
}
return metadata, nil
}
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
if metadata == nil {
return ""
}
result := format
year := metadata.Year
if len(year) >= 4 {
year = year[:4]
}
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
} else {
result = strings.ReplaceAll(result, "{track}", "")
}
if metadata.DiscNumber > 0 {
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
} else {
result = strings.ReplaceAll(result, "{disc}", "")
}
result = strings.TrimSpace(result)
result = strings.Join(strings.Fields(result), " ")
result = strings.Trim(result, " -._")
if result == "" {
return ""
}
return result + ext
}
func sanitizeFilenameForRename(name string) string {
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
result := name
for _, char := range invalid {
result = strings.ReplaceAll(result, char, "")
}
return strings.TrimSpace(result)
}
func PreviewRename(files []string, format string) []RenamePreview {
var previews []RenamePreview
for _, filePath := range files {
preview := RenamePreview{
OldPath: filePath,
OldName: filepath.Base(filePath),
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
preview.Error = err.Error()
previews = append(previews, preview)
continue
}
preview.Metadata = *metadata
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
preview.Error = "Could not generate filename (missing metadata)"
previews = append(previews, preview)
continue
}
preview.NewName = newName
preview.NewPath = filepath.Join(filepath.Dir(filePath), newName)
previews = append(previews, preview)
}
return previews
}
func GetFileSizes(files []string) map[string]int64 {
result := make(map[string]int64)
for _, filePath := range files {
info, err := os.Stat(filePath)
if err == nil {
result[filePath] = info.Size()
}
}
return result
}
func RenameFiles(files []string, format string) []RenameResult {
var results []RenameResult
for _, filePath := range files {
result := RenameResult{
OldPath: filePath,
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
result.Error = "Could not generate filename (missing metadata)"
result.Success = false
results = append(results, result)
continue
}
newPath := filepath.Join(filepath.Dir(filePath), newName)
result.NewPath = newPath
if newPath != filePath {
if _, err := os.Stat(newPath); err == nil {
result.Error = "File already exists"
result.Success = false
results = append(results, result)
continue
}
}
if err := os.Rename(filePath, newPath); err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
result.Success = true
results = append(results, result)
}
return results
}
+88 -31
View File
@@ -5,43 +5,62 @@ import (
"path/filepath"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// BuildExpectedFilename builds the expected filename based on track metadata and settings
func BuildExpectedFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
safeTitle := SanitizeFilename(trackName)
safeArtist := SanitizeFilename(artistName)
safeAlbum := SanitizeFilename(albumName)
safeAlbumArtist := SanitizeFilename(albumArtist)
safePlaylist := SanitizeFilename(playlistName)
safeCreator := SanitizeFilename(playlistOwner)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
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)
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators like ". " or " - " or ". "
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
@@ -50,37 +69,81 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
return filename + ".flac"
}
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(name string) string {
re := regexp.MustCompile(`[<>:"/\\|?*]`)
sanitized := re.ReplaceAllString(name, "_")
func SanitizeFilename(name string) string {
sanitized := strings.ReplaceAll(name, "/", " ")
re := regexp.MustCompile(`[<>:"\\|?*]`)
sanitized = re.ReplaceAllString(sanitized, " ")
var result strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
result.WriteRune(r)
}
sanitized = result.String()
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ". ")
re = regexp.MustCompile(`\s+`)
sanitized = re.ReplaceAllString(sanitized, " ")
re = regexp.MustCompile(`_+`)
sanitized = re.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
if sanitized == "" {
return "Unknown"
}
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
return sanitized
}
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
func NormalizePath(folderPath string) string {
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
}
func SanitizeFolderPath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
// Detect separator
sep := string(filepath.Separator)
// Split path into components
parts := strings.Split(normalizedPath, sep)
sanitizedParts := make([]string, 0, len(parts))
for i, part := range parts {
// Keep drive letter intact on Windows (e.g., "C:")
if i == 0 && len(part) == 2 && part[1] == ':' {
sanitizedParts = append(sanitizedParts, part)
continue
}
// Sanitize each folder name (but don't replace / or \ since we already normalized)
if i == 0 && part == "" {
sanitizedParts = append(sanitizedParts, part)
continue
}
sanitized := sanitizeFolderName(part)
if sanitized != "" {
sanitizedParts = append(sanitizedParts, sanitized)
@@ -90,14 +153,8 @@ func SanitizeFolderPath(folderPath string) string {
return strings.Join(sanitizedParts, sep)
}
// sanitizeFolderName removes invalid characters from a single folder name
func sanitizeFolderName(name string) string {
// Remove or replace invalid characters for folder names (excluding path separators)
re := regexp.MustCompile(`[<>:"|?*]`)
sanitized := re.ReplaceAllString(name, "_")
sanitized = strings.TrimSpace(sanitized)
if sanitized == "" {
return "Unknown"
}
return sanitized
func sanitizeFolderName(name string) string { return SanitizeFilename(name) }
func sanitizeFilename(name string) string {
return SanitizeFilename(name)
}
+25 -4
View File
@@ -14,7 +14,7 @@ func OpenFolderInExplorer(path string) error {
switch runtime.GOOS {
case "windows":
cmd = exec.Command("explorer", path)
case "darwin": // macOS
case "darwin":
cmd = exec.Command("open", path)
case "linux":
cmd = exec.Command("xdg-open", path)
@@ -26,7 +26,7 @@ func OpenFolderInExplorer(path string) error {
}
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
// If defaultPath is empty, use default music path
if defaultPath == "" {
defaultPath = GetDefaultMusicPath()
}
@@ -41,7 +41,6 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
return "", err
}
// If user cancelled, selectedPath will be empty
if selectedPath == "" {
return "", nil
}
@@ -69,10 +68,32 @@ func SelectFileDialog(ctx context.Context) (string, error) {
return "", err
}
// If user cancelled, selectedFile will be empty
if selectedFile == "" {
return "", nil
}
return selectedFile, nil
}
func SelectImageVideoDialog(ctx context.Context) ([]string, error) {
options := wailsRuntime.OpenDialogOptions{
Title: "Select Image or Video",
Filters: []wailsRuntime.FileFilter{
{
DisplayName: "Supported Files (*.jpg, *.png, *.mp4, *.mov, ...)",
Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.mp4;*.mkv;*.webm;*.mov",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
}
selectedPaths, err := wailsRuntime.OpenMultipleFilesDialog(ctx, options)
if err != nil {
return nil, err
}
return selectedPaths, nil
}
+326
View File
@@ -0,0 +1,326 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
type HistoryItem struct {
ID string `json:"id"`
SpotifyID string `json:"spotify_id"`
Title string `json:"title"`
Artists string `json:"artists"`
Album string `json:"album"`
DurationStr string `json:"duration_str"`
CoverURL string `json:"cover_url"`
Quality string `json:"quality"`
Format string `json:"format"`
Path string `json:"path"`
Timestamp int64 `json:"timestamp"`
}
var historyDB *bolt.DB
const (
historyBucket = "DownloadHistory"
maxHistory = 10000
)
func InitHistoryDB(appName string) error {
appDir, err := GetFFmpegDir()
if err != nil {
return err
}
if _, err := os.Stat(appDir); os.IsNotExist(err) {
os.MkdirAll(appDir, 0755)
}
dbPath := filepath.Join(appDir, "history.db")
db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return err
}
err = db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
return err
})
if err != nil {
db.Close()
return err
}
historyDB = db
return nil
}
func CloseHistoryDB() {
if historyDB != nil {
historyDB.Close()
}
}
func AddHistoryItem(item HistoryItem, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
if err != nil {
return err
}
id, _ := b.NextSequence()
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
item.Timestamp = time.Now().Unix()
buf, err := json.Marshal(item)
if err != nil {
return err
}
if b.Stats().KeyN >= maxHistory {
c := b.Cursor()
toDelete := maxHistory / 20
if toDelete < 1 {
toDelete = 1
}
count := 0
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
if err := b.Delete(k); err != nil {
return err
}
count++
}
}
return b.Put([]byte(item.ID), buf)
})
}
func GetHistoryItems(appName string) ([]HistoryItem, error) {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return nil, err
}
}
var items []HistoryItem
err := historyDB.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item HistoryItem
if err := json.Unmarshal(v, &item); err == nil {
items = append(items, item)
}
}
return nil
})
sort.Slice(items, func(i, j int) bool {
return items[i].Timestamp > items[j].Timestamp
})
return items, err
}
func ClearHistory(appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
return tx.DeleteBucket([]byte(historyBucket))
})
}
type FetchHistoryItem struct {
ID string `json:"id"`
URL string `json:"url"`
Type string `json:"type"`
Name string `json:"name"`
Info string `json:"info"`
Image string `json:"image"`
Data string `json:"data"`
Timestamp int64 `json:"timestamp"`
}
const (
fetchHistoryBucket = "FetchHistory"
)
func AddFetchHistoryItem(item FetchHistoryItem, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(fetchHistoryBucket))
if err != nil {
return err
}
id, _ := b.NextSequence()
if item.URL != "" {
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var existing FetchHistoryItem
if err := json.Unmarshal(v, &existing); err == nil {
if existing.URL == item.URL && existing.Type == item.Type {
if err := b.Delete(k); err != nil {
return err
}
}
}
}
}
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
item.Timestamp = time.Now().Unix()
buf, err := json.Marshal(item)
if err != nil {
return err
}
if b.Stats().KeyN >= maxHistory {
c := b.Cursor()
toDelete := maxHistory / 20
if toDelete < 1 {
toDelete = 1
}
count := 0
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
if err := b.Delete(k); err != nil {
return err
}
count++
}
}
return b.Put([]byte(item.ID), buf)
})
}
func GetFetchHistoryItems(appName string) ([]FetchHistoryItem, error) {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return nil, err
}
}
var items []FetchHistoryItem
err := historyDB.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item FetchHistoryItem
if err := json.Unmarshal(v, &item); err == nil {
items = append(items, item)
}
}
return nil
})
sort.Slice(items, func(i, j int) bool {
return items[i].Timestamp > items[j].Timestamp
})
return items, err
}
func ClearFetchHistory(appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
return tx.DeleteBucket([]byte(fetchHistoryBucket))
})
}
func ClearFetchHistoryByType(itemType string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
var keysToDelete [][]byte
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item FetchHistoryItem
if err := json.Unmarshal(v, &item); err == nil {
if item.Type == itemType {
keysToDelete = append(keysToDelete, k)
}
}
}
for _, k := range keysToDelete {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
})
}
func DeleteHistoryItem(id string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket))
if b == nil {
return nil
}
return b.Delete([]byte(id))
})
}
func DeleteFetchHistoryItem(id string, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(fetchHistoryBucket))
if b == nil {
return nil
}
return b.Delete([]byte(id))
})
}
+290 -48
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -13,33 +14,45 @@ import (
"time"
)
// LyricsLine represents a single line of lyrics
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Duration float64 `json:"duration"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"`
}
type LyricsLine struct {
StartTimeMs string `json:"startTimeMs"`
Words string `json:"words"`
EndTimeMs string `json:"endTimeMs"`
}
// LyricsResponse represents the API response
type LyricsResponse struct {
Error bool `json:"error"`
SyncType string `json:"syncType"`
Lines []LyricsLine `json:"lines"`
}
// LyricsDownloadRequest represents a request to download lyrics
type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
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"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"`
DiscNumber int `json:"disc_number"`
}
// LyricsDownloadResponse represents the response from lyrics download
type LyricsDownloadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
@@ -48,72 +61,226 @@ type LyricsDownloadResponse struct {
AlreadyExists bool `json:"already_exists,omitempty"`
}
// LyricsClient handles lyrics fetching
type LyricsClient struct {
httpClient *http.Client
}
// NewLyricsClient creates a new lyrics client
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// FetchLyrics fetches lyrics from the Spotify Lyrics API
func (c *LyricsClient) FetchLyrics(spotifyID string) (*LyricsResponse, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9zcG90aWZ5LWx5cmljcy1hcGktcGkudmVyY2VsLmFwcC8/dHJhY2tpZD0=")
url := fmt.Sprintf("%s%s", string(apiBase), spotifyID)
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
resp, err := c.httpClient.Get(url)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase),
url.QueryEscape(artistName),
url.QueryEscape(trackName))
if duration > 0 {
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
}
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch lyrics: %v", err)
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
return nil, fmt.Errorf("failed to read LRCLIB response: %v", err)
}
var lyricsResp LyricsResponse
if err := json.Unmarshal(body, &lyricsResp); err != nil {
return nil, fmt.Errorf("failed to parse lyrics response: %v", err)
var lrcLibResp LRCLibResponse
if err := json.Unmarshal(body, &lrcLibResp); err != nil {
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
}
if lyricsResp.Error {
return nil, fmt.Errorf("lyrics not found for this track")
}
return &lyricsResp, nil
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
}
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
resp := &LyricsResponse{
Error: false,
SyncType: "LINE_SYNCED",
Lines: []LyricsLine{},
}
lyricsText := lrcLib.SyncedLyrics
if lyricsText == "" {
lyricsText = lrcLib.PlainLyrics
resp.SyncType = "UNSYNCED"
}
if lyricsText == "" {
resp.Error = true
return resp
}
lines := strings.Split(lyricsText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "[") && len(line) > 10 {
closeBracket := strings.Index(line, "]")
if closeBracket > 0 {
timestamp := line[1:closeBracket]
words := strings.TrimSpace(line[closeBracket+1:])
ms := lrcTimestampToMs(timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
Words: words,
})
continue
}
}
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: "",
Words: line,
})
}
return resp
}
func lrcTimestampToMs(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 0
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read failed: %v", err)
}
var results []LRCLibResponse
if err := json.Unmarshal(body, &results); err != nil {
return nil, fmt.Errorf("parse failed: %v", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("no results found")
}
var best *LRCLibResponse
for i := range results {
if results[i].SyncedLyrics != "" {
best = &results[i]
break
}
if best == nil && results[i].PlainLyrics != "" {
best = &results[i]
}
}
if best == nil {
best = &results[0]
}
return c.convertLRCLibToLyricsResponse(best), nil
}
func simplifyTrackName(name string) string {
if idx := strings.Index(name, "("); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
if idx := strings.Index(name, " - "); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
return name
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil
}
fmt.Printf(" LRCLIB exact: %v\n", err)
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search", nil
}
fmt.Printf(" LRCLIB search: %v\n", err)
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB (simplified)", nil
}
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search (simplified)", nil
}
}
return nil, "", fmt.Errorf("lyrics not found in any source")
}
// ConvertToLRC converts lyrics response to LRC format
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
var sb strings.Builder
// Add metadata
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
sb.WriteString("[by:SpotiFlac]\n")
sb.WriteString("\n")
// Add lyrics lines
for _, line := range lyrics.Lines {
if line.Words == "" {
continue
}
// Convert milliseconds to LRC timestamp format [mm:ss.xx]
timestamp := msToLRCTimestamp(line.StartTimeMs)
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
if line.StartTimeMs == "" {
sb.WriteString(fmt.Sprintf("%s\n", line.Words))
} else {
timestamp := msToLRCTimestamp(line.StartTimeMs)
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
}
}
return sb.String()
}
// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx]
func msToLRCTimestamp(msStr string) string {
var ms int64
fmt.Sscanf(msStr, "%d", &ms)
@@ -126,40 +293,52 @@ func msToLRCTimestamp(msStr string) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
// Check if format is a template (contains {})
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
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 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
@@ -168,7 +347,47 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
return filename + ".lrc"
}
// DownloadLyrics downloads lyrics for a single track
func findAudioFileForLyrics(dir, trackName, artistName string) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"}
patterns := []string{
fmt.Sprintf("%s - %s", safeTitle, safeArtist),
fmt.Sprintf("%s - %s", safeArtist, safeTitle),
safeTitle,
}
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
for _, pattern := range patterns {
if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) {
ext := strings.ToLower(filepath.Ext(filename))
for _, audioExt := range audioExts {
if ext == strings.ToLower(audioExt) {
return filepath.Join(dir, filename)
}
}
}
}
}
return ""
}
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
if req.SpotifyID == "" {
return &LyricsDownloadResponse{
@@ -177,12 +396,30 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, fmt.Errorf("spotify ID is required")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = SanitizeFolderPath(outputDir)
outputDir = NormalizePath(outputDir)
}
safeArtist := sanitizeFilename(req.AlbumArtist)
if safeArtist == "" {
safeArtist = sanitizeFilename(req.ArtistName)
}
safeAlbum := sanitizeFilename(req.AlbumName)
if safeArtist != "" && safeAlbum != "" {
artistAlbumPath := filepath.Join(outputDir, safeArtist, safeAlbum)
if info, err := os.Stat(artistAlbumPath); err == nil && info.IsDir() {
outputDir = artistAlbumPath
} else {
artistPath := filepath.Join(outputDir, safeArtist)
if info, err := os.Stat(artistPath); err == nil && info.IsDir() {
outputDir = artistPath
}
}
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -192,15 +429,13 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
filenameFormat = "title-artist"
}
filename := buildLyricsFilename(req.TrackName, req.ArtistName, 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)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &LyricsDownloadResponse{
Success: true,
@@ -210,8 +445,17 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, nil
}
// Fetch lyrics
lyrics, err := c.FetchLyrics(req.SpotifyID)
audioDuration := 0
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
if audioFile != "" {
duration, err := GetAudioDuration(audioFile)
if err == nil && duration > 0 {
audioDuration = int(duration)
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
}
}
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
if err != nil {
return &LyricsDownloadResponse{
Success: false,
@@ -219,10 +463,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err
}
// Convert to LRC format
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
// Write LRC file
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
return &LyricsDownloadResponse{
Success: false,
+826 -48
View File
@@ -1,11 +1,15 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
pathfilepath "path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
@@ -15,10 +19,18 @@ type Metadata struct {
Title string
Artist string
Album string
AlbumArtist string
Date string
ReleaseDate string
TrackNumber int
TotalTracks int
DiscNumber int
ISRC string
TotalDiscs int
URL string
Copyright string
Publisher string
Lyrics string
Description string
}
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
@@ -46,17 +58,36 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.Album != "" {
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
}
if metadata.AlbumArtist != "" {
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
}
if metadata.Date != "" {
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
}
if metadata.TrackNumber > 0 {
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
}
if metadata.TotalTracks > 0 {
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
if metadata.TotalDiscs > 0 {
_ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs))
}
if metadata.Copyright != "" {
_ = cmt.Add("COPYRIGHT", metadata.Copyright)
}
if metadata.Publisher != "" {
_ = cmt.Add("PUBLISHER", metadata.Publisher)
}
if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics)
}
cmtBlock := cmt.Marshal()
@@ -113,18 +144,199 @@ func fileExists(path string) bool {
return err == 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")
func extractYear(releaseDate string) string {
if releaseDate == "" {
return ""
}
if len(releaseDate) >= 4 {
return releaseDate[:4]
}
return releaseDate
}
func EmbedLyricsOnly(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
f, err := flac.ParseFile(filepath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx = -1
var existingCmt *flacvorbis.MetaDataBlockVorbisComment
for idx, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmtIdx = idx
existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
existingCmt = nil
}
break
}
}
cmt := flacvorbis.New()
if existingCmt != nil {
for _, comment := range existingCmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
fieldName := strings.ToUpper(parts[0])
if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" {
_ = cmt.Add(parts[0], parts[1])
}
}
}
}
_ = cmt.Add("LYRICS", lyrics)
cmtBlock := cmt.Marshal()
if cmtIdx < 0 {
f.Meta = append(f.Meta, &cmtBlock)
} else {
f.Meta[cmtIdx] = &cmtBlock
}
if err := f.Save(filepath); err != nil {
return fmt.Errorf("failed to save FLAC file: %w", err)
}
return nil
}
func ExtractCoverArt(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return extractCoverFromMp3(filePath)
case ".m4a", ".flac":
return extractCoverFromM4AOrFlac(filePath)
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
func extractCoverFromMp3(filePath string) (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()
pictures := tag.GetFrames(tag.CommonID("Attached picture"))
if len(pictures) == 0 {
return "", fmt.Errorf("no cover art found")
}
pic, ok := pictures[0].(id3v2.PictureFrame)
if !ok {
return "", fmt.Errorf("invalid picture frame")
}
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.Write(pic.Picture); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write cover art: %w", err)
}
return tmpFile.Name(), nil
}
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
if ext == ".flac" {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.Write(pic.ImageData); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write cover art: %w", err)
}
return tmpFile.Name(), nil
}
}
return "", fmt.Errorf("no cover art found")
}
return "", nil
}
func ExtractLyrics(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return extractLyricsFromMp3(filePath)
case ".flac":
return extractLyricsFromFlac(filePath)
case ".m4a":
return "", nil
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
func extractLyricsFromMp3(filePath string) (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()
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
if len(usltFrames) == 0 {
fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath)
return "", nil
}
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
if !ok {
fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath)
return "", nil
}
if uslt.Lyrics == "" {
fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath)
return "", nil
}
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics))
return uslt.Lyrics, nil
}
func extractLyricsFromFlac(filePath string) (string, error) {
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)
@@ -132,58 +344,624 @@ func ReadISRCFromFile(filepath string) (string, error) {
continue
}
// Get ISRC field
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
if err == nil && len(isrcValues) > 0 {
return isrcValues[0], nil
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
fieldName := strings.ToUpper(parts[0])
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
lyrics := parts[1]
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics))
return lyrics, nil
}
}
}
}
}
return "", nil // No ISRC found
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
return "", nil
}
// 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
func EmbedCoverArtOnly(filePath string, coverPath string) error {
if coverPath == "" || !fileExists(coverPath) {
return nil
}
// Read all .flac files in directory
entries, err := os.ReadDir(outputDir)
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return embedCoverToMp3(filePath, coverPath)
case ".m4a":
return nil
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
func embedCoverToMp3(filePath string, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", false
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames(tag.CommonID("Attached picture"))
artwork, err := os.ReadFile(coverPath)
if err != nil {
return fmt.Errorf("failed to read cover art: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Front cover",
Picture: artwork,
}
tag.AddAttachedPicture(pic)
// 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
}
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return "", false
return nil
}
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
usltFrame := id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8,
Language: "eng",
ContentDescriptor: "",
Lyrics: lyrics,
}
tag.AddUnsynchronisedLyricsFrame(usltFrame)
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
func embedLyricsToM4A(filepath string, lyrics string) error {
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
defer func() {
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
cmd := exec.Command(ffmpegPath,
"-i", filepath,
"-map", "0",
"-map_metadata", "0",
"-metadata", "lyrics-eng="+lyrics,
"-metadata", "lyrics="+lyrics,
"-codec", "copy",
"-f", "ipod",
"-y",
tmpOutputFile,
)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output))
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
}
if err := os.Rename(tmpOutputFile, filepath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics))
return nil
}
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(filepath))
switch ext {
case ".mp3":
return EmbedLyricsOnlyMP3(filepath, lyrics)
case ".flac":
return EmbedLyricsOnly(filepath, lyrics)
case ".m4a":
return embedLyricsToM4A(filepath, lyrics)
default:
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
}
}
func GetAudioDuration(filepath string) (float64, error) {
ext := strings.ToLower(pathfilepath.Ext(filepath))
if ext == ".flac" {
duration, err := getFlacDuration(filepath)
if err == nil && duration > 0 {
return duration, nil
}
}
return getDurationWithFFprobe(filepath)
}
func getFlacDuration(filepath string) (float64, error) {
f, err := flac.ParseFile(filepath)
if err != nil {
return 0, err
}
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
totalSamples := uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if sampleRate > 0 {
return float64(totalSamples) / float64(sampleRate), nil
}
}
}
}
return 0, fmt.Errorf("could not extract duration from FLAC file")
}
func getDurationWithFFprobe(filepath string) (float64, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return 0, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return 0, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
filepath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return 0, err
}
var result struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(output, &result); err != nil {
return 0, err
}
if result.Format.Duration == "" {
return 0, fmt.Errorf("duration not found in ffprobe output")
}
duration, err := strconv.ParseFloat(result.Format.Duration, 64)
if err != nil {
return 0, err
}
return duration, nil
}
func validateLyricsDuration(lyrics string, filepath string) (string, error) {
duration, err := GetAudioDuration(filepath)
if err != nil {
fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err)
return lyrics, nil
}
if duration <= 0 {
fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration)
return lyrics, nil
}
durationMs := int64(duration * 1000)
lines := strings.Split(lyrics, "\n")
var validLines []string
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
validLines = append(validLines, line)
continue
}
if strings.HasPrefix(trimmedLine, "[") {
if strings.Index(trimmedLine, ":") > 0 {
validLines = append(validLines, line)
continue
}
closeBracket := strings.Index(trimmedLine, "]")
if closeBracket > 0 {
timestampStr := trimmedLine[1:closeBracket]
ms := parseLRCTimestamp(timestampStr)
if ms >= 0 && ms <= durationMs {
validLines = append(validLines, line)
} else {
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
}
} else {
validLines = append(validLines, line)
}
} else {
validLines = append(validLines, line)
}
}
return strings.Join(validLines, "\n"), nil
}
func parseLRCTimestamp(timestamp string) int64 {
var minutes, seconds, centiseconds int64
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return -1
}
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
var metadata Metadata
ffprobePath, err := GetFFprobePath()
if err != nil {
return metadata, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return metadata, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return metadata, err
}
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return metadata, err
}
allTags := make(map[string]string)
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
for key, value := range allTags {
switch key {
case "title":
metadata.Title = value
case "artist":
metadata.Artist = value
case "album":
metadata.Album = value
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "date", "year":
if metadata.Date == "" || len(value) > len(metadata.Date) {
metadata.Date = value
}
case "track":
parts := strings.Split(value, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[0]); err == nil {
metadata.TrackNumber = num
}
}
if len(parts) > 1 {
if num, err := strconv.Atoi(parts[1]); err == nil {
metadata.TotalTracks = num
}
}
case "disc":
parts := strings.Split(value, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[0]); err == nil {
metadata.DiscNumber = num
}
}
if len(parts) > 1 {
if num, err := strconv.Atoi(parts[1]); err == nil {
metadata.TotalDiscs = num
}
}
case "copyright", "tcop":
metadata.Copyright = value
case "publisher", "tpub", "label":
metadata.Publisher = value
case "url":
metadata.URL = value
case "description", "comment":
if metadata.Description == "" {
metadata.Description = value
}
}
}
return metadata, nil
}
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".flac":
return EmbedMetadata(filePath, metadata, coverPath)
case ".mp3":
return embedMetadataToMP3(filePath, metadata, coverPath)
case ".m4a":
return embedMetadataToM4A(filePath, metadata, coverPath)
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames("TXXX")
if metadata.Title != "" {
tag.SetTitle(metadata.Title)
}
if metadata.Artist != "" {
tag.SetArtist(metadata.Artist)
}
if metadata.Album != "" {
tag.SetAlbum(metadata.Album)
}
if metadata.Date != "" {
year := metadata.Date
if len(year) >= 4 {
year = year[:4]
}
tag.SetYear(year)
}
if metadata.AlbumArtist != "" {
tag.DeleteFrames("TPE2")
tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist)
}
if metadata.TrackNumber > 0 {
tag.DeleteFrames(tag.CommonID("Track number/Position in set"))
trackStr := strconv.Itoa(metadata.TrackNumber)
if metadata.TotalTracks > 0 {
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
}
tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr)
}
if metadata.DiscNumber > 0 {
tag.DeleteFrames(tag.CommonID("Part of a set"))
discStr := strconv.Itoa(metadata.DiscNumber)
if metadata.TotalDiscs > 0 {
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
}
tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr)
}
if metadata.Copyright != "" {
tag.DeleteFrames("TCOP")
tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright)
}
if metadata.Publisher != "" {
tag.DeleteFrames("TPUB")
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
}
if coverPath != "" && fileExists(coverPath) {
tag.DeleteFrames(tag.CommonID("Attached picture"))
artwork, err := os.ReadFile(coverPath)
if err == nil {
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: artwork,
}
tag.AddAttachedPicture(pic)
} else {
fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err)
}
}
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
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"
)
// DownloadStatus represents the status of a download item
type DownloadStatus string
const (
@@ -18,7 +17,6 @@ const (
StatusSkipped DownloadStatus = "skipped"
)
// DownloadItem represents a single item in the download queue
type DownloadItem struct {
ID string `json:"id"`
TrackName string `json:"track_name"`
@@ -26,16 +24,15 @@ type DownloadItem struct {
AlbumName string `json:"album_name"`
ISRC string `json:"isrc"`
Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"` // MB downloaded
TotalSize float64 `json:"total_size"` // MB total (if known)
Speed float64 `json:"speed"` // MB/s
StartTime int64 `json:"start_time"` // Unix timestamp
EndTime int64 `json:"end_time"` // Unix timestamp
ErrorMessage string `json:"error_message"` // If failed
FilePath string `json:"file_path"` // Final file path
Progress float64 `json:"progress"`
TotalSize float64 `json:"total_size"`
Speed float64 `json:"speed"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
ErrorMessage string `json:"error_message"`
FilePath string `json:"file_path"`
}
// Global progress tracker
var (
currentProgress float64
currentProgressLock sync.RWMutex
@@ -44,7 +41,6 @@ var (
currentSpeed float64
speedLock sync.RWMutex
// Download queue tracking
downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex
currentItemID string
@@ -55,27 +51,24 @@ var (
sessionStartLock sync.RWMutex
)
// ProgressInfo represents download progress information
type ProgressInfo struct {
IsDownloading bool `json:"is_downloading"`
MBDownloaded float64 `json:"mb_downloaded"`
SpeedMBps float64 `json:"speed_mbps"`
}
// DownloadQueueInfo represents the complete download queue state
type DownloadQueueInfo struct {
IsDownloading bool `json:"is_downloading"`
Queue []DownloadItem `json:"queue"`
CurrentSpeed float64 `json:"current_speed"` // MB/s
TotalDownloaded float64 `json:"total_downloaded"` // MB this session
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
CurrentSpeed float64 `json:"current_speed"`
TotalDownloaded float64 `json:"total_downloaded"`
SessionStartTime int64 `json:"session_start_time"`
QueuedCount int `json:"queued_count"`
CompletedCount int `json:"completed_count"`
FailedCount int `json:"failed_count"`
SkippedCount int `json:"skipped_count"`
}
// GetDownloadProgress returns current download progress
func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock()
downloading := isDownloading
@@ -96,34 +89,30 @@ func GetDownloadProgress() ProgressInfo {
}
}
// SetDownloadSpeed updates the current download speed
func SetDownloadSpeed(mbps float64) {
speedLock.Lock()
currentSpeed = mbps
speedLock.Unlock()
}
// SetDownloadProgress updates the current download progress
func SetDownloadProgress(mbDownloaded float64) {
currentProgressLock.Lock()
currentProgress = mbDownloaded
currentProgressLock.Unlock()
}
// SetDownloading sets the downloading state
func SetDownloading(downloading bool) {
downloadingLock.Lock()
isDownloading = downloading
downloadingLock.Unlock()
if !downloading {
// Reset progress when download completes
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
}
// ProgressWriter wraps an io.Writer and reports download progress
type ProgressWriter struct {
writer io.Writer
total int64
@@ -131,7 +120,7 @@ type ProgressWriter struct {
startTime int64
lastTime int64
lastBytes int64
itemID string // Track which download item this belongs to
itemID string
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
@@ -147,7 +136,6 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
}
}
// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
pw := NewProgressWriter(writer)
pw.itemID = itemID
@@ -162,13 +150,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.total += int64(n)
// Report progress every 256KB for smoother updates
if pw.total-pw.lastPrinted >= 256*1024 {
mbDownloaded := float64(pw.total) / (1024 * 1024)
// Calculate speed (MB/s)
now := getCurrentTimeMillis()
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
timeDiff := float64(now-pw.lastTime) / 1000.0
bytesDiff := float64(pw.total - pw.lastBytes)
var speedMBps float64
@@ -180,10 +166,8 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
}
// Update global progress
SetDownloadProgress(mbDownloaded)
// Update individual item progress if we have an item ID
if pw.itemID != "" {
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
}
@@ -200,9 +184,6 @@ func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
// Queue management functions
// AddToQueue adds a new item to the download queue
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -223,7 +204,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueue = append(downloadQueue, item)
// Initialize session start time if this is the first item
sessionStartLock.Lock()
if sessionStartTime == 0 {
sessionStartTime = time.Now().Unix()
@@ -231,7 +211,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
sessionStartLock.Unlock()
}
// StartDownloadItem marks an item as currently downloading
func StartDownloadItem(id string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -250,7 +229,6 @@ func StartDownloadItem(id string) {
currentItemLock.Unlock()
}
// UpdateItemProgress updates the progress of the current download item
func UpdateItemProgress(id string, progress, speed float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -264,14 +242,12 @@ func UpdateItemProgress(id string, progress, speed float64) {
}
}
// GetCurrentItemID returns the ID of the currently downloading item
func GetCurrentItemID() string {
currentItemLock.RLock()
defer currentItemLock.RUnlock()
return currentItemID
}
// CompleteDownloadItem marks an item as completed
func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -284,7 +260,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueue[i].Progress = finalSize
downloadQueue[i].TotalSize = finalSize
// Add to total downloaded
totalDownloadedLock.Lock()
totalDownloaded += finalSize
totalDownloadedLock.Unlock()
@@ -293,7 +268,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
}
}
// FailDownloadItem marks an item as failed
func FailDownloadItem(id, errorMsg string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -308,7 +282,6 @@ func FailDownloadItem(id, errorMsg string) {
}
}
// SkipDownloadItem marks an item as skipped (already exists)
func SkipDownloadItem(id, filePath string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -323,9 +296,8 @@ func SkipDownloadItem(id, filePath string) {
}
}
// GetDownloadQueue returns the complete download queue state
func GetDownloadQueue() DownloadQueueInfo {
// Auto-reset session if all downloads are complete
ResetSessionIfComplete()
downloadQueueLock.RLock()
@@ -347,7 +319,6 @@ func GetDownloadQueue() DownloadQueueInfo {
sessionStart := sessionStartTime
sessionStartLock.RUnlock()
// Count statuses
var queued, completed, failed, skipped int
for _, item := range downloadQueue {
switch item.Status {
@@ -362,7 +333,6 @@ func GetDownloadQueue() DownloadQueueInfo {
}
}
// Create a copy of the queue
queueCopy := make([]DownloadItem, len(downloadQueue))
copy(queueCopy, downloadQueue)
@@ -379,12 +349,10 @@ func GetDownloadQueue() DownloadQueueInfo {
}
}
// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
func ClearDownloadQueue() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
// Keep only queued and downloading items
newQueue := make([]DownloadItem, 0)
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
@@ -394,7 +362,6 @@ func ClearDownloadQueue() {
downloadQueue = newQueue
}
// ClearAllDownloads clears the entire queue and resets session stats
func ClearAllDownloads() {
downloadQueueLock.Lock()
downloadQueue = []DownloadItem{}
@@ -412,13 +379,10 @@ func ClearAllDownloads() {
currentItemID = ""
currentItemLock.Unlock()
// Reset current progress and speed
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
// CancelAllQueuedItems marks all queued items as skipped (cancelled)
// This is called when user stops a download or when batch download completes
func CancelAllQueuedItems() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
@@ -432,8 +396,6 @@ func CancelAllQueuedItems() {
}
}
// ResetSessionIfComplete resets session stats if no active or queued downloads
// Note: Does NOT clear the queue - items remain visible for history
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
@@ -445,8 +407,6 @@ func ResetSessionIfComplete() {
}
downloadQueueLock.RUnlock()
// If no active or queued items, reset session stats
// But keep the queue items for history visibility
if !hasActiveOrQueued {
sessionStartLock.Lock()
sessionStartTime = 0
+242 -114
View File
@@ -1,10 +1,10 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"path/filepath"
@@ -78,9 +78,8 @@ func NewQobuzDownloader() *QobuzDownloader {
}
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
resp, err := q.client.Get(url)
if err != nil {
@@ -93,8 +92,23 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
var searchResp QobuzSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &searchResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if len(searchResp.Tracks.Items) == 0 {
@@ -104,72 +118,204 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
return &searchResp.Tracks.Items[0], nil
}
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 := "27" // Default to Hi-Res
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
// Decode base64 API URLs
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
// Try primary API first
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
resp, err := q.client.Get(primaryURL)
if err == nil && resp.StatusCode == 200 {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Primary API response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
fmt.Printf("Got download URL from primary API\n")
return streamResp.URL, nil
}
func decodeXOR(data []byte) string {
text := string(data)
runes := []rune(text)
result := make([]rune, len(runes))
for i, char := range runes {
key := rune((i * 17) % 128)
result[i] = char ^ 253 ^ key
}
if resp != nil {
resp.Body.Close()
return string(result)
}
func (q *QobuzDownloader) mapJumoQuality(quality string) int {
switch quality {
case "6":
return 6
case "7":
return 7
case "27":
return 27
default:
return 6
}
}
// Fallback to secondary API
fmt.Println("Primary API failed, trying fallback...")
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
formatID := q.mapJumoQuality(quality)
region := "US"
url := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
resp, err = q.client.Get(fallbackURL)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Fallback API error response: %s\n", string(body))
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Fallback API response: %s\n", string(body))
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
decoded := decodeXOR(body)
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
return "", fmt.Errorf("failed to parse JSON (plain or XOR): %w", err)
}
}
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
return urlVal, nil
}
if data, ok := result["data"].(map[string]interface{}); ok {
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
return urlVal, nil
}
}
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
return linkVal, nil
}
return "", fmt.Errorf("URL not found in Jumo response")
}
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
resp, err := q.client.Get(apiURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if len(body) == 0 {
return "", fmt.Errorf("empty body")
}
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
return streamResp.URL, nil
}
if streamResp.URL == "" {
return "", fmt.Errorf("no download URL available")
return "", fmt.Errorf("invalid response")
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
qualityCode := quality
if qualityCode == "" || qualityCode == "5" {
qualityCode = "6"
}
fmt.Printf("Got download URL from fallback API\n")
return streamResp.URL, nil
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
standardAPIs := []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.squid.wtf/api/download-music?track_id=",
}
downloadFunc := func(qual string) (string, error) {
type Provider struct {
Name string
Func func() (string, error)
}
var providers []Provider
for _, api := range standardAPIs {
currentAPI := api
providers = append(providers, Provider{
Name: "Standard(" + currentAPI + ")",
Func: func() (string, error) {
return q.DownloadFromStandard(currentAPI, trackID, qual)
},
})
}
providers = append(providers, Provider{
Name: "Jumo-DL",
Func: func() (string, error) {
return q.DownloadFromJumo(trackID, qual)
},
})
rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] })
var lastErr error
for _, p := range providers {
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
url, err := p.Func()
if err == nil {
fmt.Printf("✓ Success\n")
return url, nil
}
fmt.Printf("Provider failed: %v\n", err)
lastErr = err
}
return "", lastErr
}
url, err := downloadFunc(qualityCode)
if err == nil {
return url, nil
}
currentQuality := qualityCode
if currentQuality == "27" && allowFallback {
fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
url, err := downloadFunc("7")
if err == nil {
fmt.Println("✓ Success with fallback quality 7")
return url, nil
}
currentQuality = "7"
}
if currentQuality == "7" && allowFallback {
fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
url, err := downloadFunc("6")
if err == nil {
fmt.Println("✓ Success with fallback quality 6")
return url, nil
}
}
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
}
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
resp, err := q.client.Get(url)
downloadClient := &http.Client{
Timeout: 5 * time.Minute,
}
resp, err := downloadClient.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -187,14 +333,13 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
@@ -224,42 +369,52 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
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
// Determine track number to use
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
// Check if format is a template (contains {})
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
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 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
default:
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
@@ -268,44 +423,24 @@ func buildQobuzFilename(title, artist string, trackNumber int, format string, in
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
// Create output directory if it doesn't exist
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.SearchByISRC(isrc)
track, err := q.SearchByISRC(deezerISRC)
if err != nil {
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Performer.Name
if track.Album.Artist.Name != "" {
artists = track.Album.Artist.Name
}
}
if trackTitle == "" {
trackTitle = track.Title
if track.Version != "" && track.Version != "null" {
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
}
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
@@ -316,7 +451,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Printf("Quality: %s\n", qualityInfo)
fmt.Println("Getting download URL...")
downloadURL, err := q.GetDownloadURL(track.ID, quality)
downloadURL, err := q.GetDownloadURL(track.ID, quality, allowFallback)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
@@ -325,7 +460,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
return "", fmt.Errorf("received empty download URL")
}
// Show partial URL for security
urlPreview := downloadURL
if len(downloadURL) > 60 {
urlPreview = downloadURL[:60] + "..."
@@ -334,15 +468,10 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Check if file with same ISRC already exists
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
return "EXISTS:" + existingFile, nil
}
// Build filename based on format settings
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
@@ -358,41 +487,40 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.Image.Large != "" {
if spotifyCoverURL != "" {
coverPath = filepath + ".cover.jpg"
fmt.Println("Downloading cover art...")
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
fmt.Println("Embedding metadata and cover art...")
releaseYear := ""
if len(track.ReleaseDateOriginal) >= 4 {
releaseYear = track.ReleaseDateOriginal[:4]
}
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && track.TrackNumber > 0 {
trackNumberToEmbed = track.TrackNumber
} else {
trackNumberToEmbed = position
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: releaseYear,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
DiscNumber: track.MediaNumber,
ISRC: track.ISRC,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
-222
View File
@@ -1,222 +0,0 @@
package backend
import (
"strings"
"unicode"
)
// Hiragana to Romaji mapping
var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
'や': "ya", 'ゆ': "yu", 'よ': "yo",
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
'わ': "wa", 'を': "wo", 'ん': "n",
// Dakuten (voiced)
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
// Handakuten (semi-voiced)
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
// Small characters
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'っ': "", // Double consonant marker
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
}
// Katakana to Romaji mapping
var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", '': "no",
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
// Dakuten (voiced)
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
// Handakuten (semi-voiced)
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
// Small characters
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ッ': "", // Double consonant marker
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
// Extended katakana
'ー': "", // Long vowel mark
'ヴ': "vu",
}
// Combination mappings for きゃ, しゃ, etc.
var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
}
var combinationKatakana = map[string]string{
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
"シャ": "sha", "シュ": "shu", "ショ": "sho",
"チャ": "cha", "チュ": "chu", "チョ": "cho",
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
// Extended combinations
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
}
// ContainsJapanese checks if a string contains Japanese characters
func ContainsJapanese(s string) bool {
for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) {
return true
}
}
return false
}
func isHiragana(r rune) bool {
return r >= 0x3040 && r <= 0x309F
}
func isKatakana(r rune) bool {
return r >= 0x30A0 && r <= 0x30FF
}
func isKanji(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
}
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) {
return text
}
var result strings.Builder
runes := []rune(text)
i := 0
for i < len(runes) {
// Check for っ/ッ (double consonant)
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
nextRomaji := ""
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
nextRomaji = romaji
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
nextRomaji = romaji
}
if len(nextRomaji) > 0 {
result.WriteByte(nextRomaji[0]) // Double the first consonant
}
i++
continue
}
// Check for two-character combinations
if i < len(runes)-1 {
combo := string(runes[i : i+2])
if romaji, ok := combinationHiragana[combo]; ok {
result.WriteString(romaji)
i += 2
continue
}
if romaji, ok := combinationKatakana[combo]; ok {
result.WriteString(romaji)
i += 2
continue
}
}
// Single character conversion
r := runes[i]
if romaji, ok := hiraganaToRomaji[r]; ok {
result.WriteString(romaji)
} else if romaji, ok := katakanaToRomaji[r]; ok {
result.WriteString(romaji)
} else if isKanji(r) {
// Keep kanji as-is (would need dictionary for proper conversion)
result.WriteRune(r)
} else {
// Keep other characters (punctuation, spaces, etc.)
result.WriteRune(r)
}
i++
}
return result.String()
}
// BuildSearchQuery creates a search query from track name and artist
// Converts Japanese to romaji if present
func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName)
artistRomaji := JapaneseToRomaji(artistName)
// Clean up the query - remove special characters that might interfere with search
trackClean := cleanSearchQuery(trackRomaji)
artistClean := cleanSearchQuery(artistRomaji)
return strings.TrimSpace(artistClean + " " + trackClean)
}
// cleanSearchQuery removes special characters that might interfere with search
func cleanSearchQuery(s string) string {
var result strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
result.WriteRune(r)
} else if r == '-' || r == '\'' {
result.WriteRune(r)
}
}
return strings.TrimSpace(result.String())
}
// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
// This is useful for creating search queries that work better with Tidal's search
func cleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
// Keep only ASCII letters, numbers, spaces, and basic punctuation
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
result.WriteRune(r)
} else if r == ',' || r == '.' {
// Convert punctuation to space
result.WriteRune(' ')
}
}
// Clean up multiple spaces
cleaned := strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(cleaned)
}
+189 -47
View File
@@ -4,8 +4,10 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
@@ -18,19 +20,15 @@ type SongLinkClient struct {
type SongLinkURLs struct {
TidalURL string `json:"tidal_url"`
DeezerURL string `json:"deezer_url"`
AmazonURL string `json:"amazon_url"`
}
// TrackAvailability represents the availability of a track on different platforms
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Deezer bool `json:"deezer"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
TidalURL string `json:"tidal_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
}
@@ -44,15 +42,14 @@ func NewSongLinkClient() *SongLinkClient {
}
}
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
@@ -63,7 +60,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
@@ -74,13 +70,16 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
}
}
// Decode base64 API URL
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))
if region != "" {
apiURL += fmt.Sprintf("&userCountry=%s", region)
}
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -88,7 +87,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
fmt.Println("Getting streaming URLs from song.link...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -97,7 +95,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return nil, fmt.Errorf("failed to get URLs: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
@@ -126,52 +123,56 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
urls := &SongLinkURLs{}
// Extract Tidal URL
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
urls.TidalURL = tidalLink.URL
fmt.Printf("✓ Tidal URL found\n")
}
// Extract Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
urls.DeezerURL = deezerLink.URL
fmt.Printf("✓ Deezer URL found\n")
}
// Extract Amazon URL
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if len(amazonURL) > 0 {
urls.AmazonURL = amazonURL
fmt.Printf("✓ Amazon URL found\n")
}
}
// Check if at least one URL was found
if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" {
if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
return urls, nil
}
// CheckTrackAvailability checks the availability of a track on different platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
@@ -182,7 +183,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
@@ -193,7 +193,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -207,7 +206,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -216,7 +214,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return nil, fmt.Errorf("failed to check availability: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
@@ -245,47 +242,56 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Qobuz using ISRC (song.link doesn't support Qobuz)
if isrc != "" {
qobuzAvailable := checkQobuzAvailability(isrc)
availability.Qobuz = qobuzAvailable
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
deezerURL := deezerLink.URL
deezerISRC, err := GetDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" {
qobuzAvailable := checkQobuzAvailability(deezerISRC)
availability.Qobuz = qobuzAvailable
}
}
return availability, nil
}
// checkQobuzAvailability checks if a track is available on Qobuz using ISRC
func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
@@ -310,3 +316,139 @@ func checkQobuzAvailability(isrc string) bool {
return searchResp.Tracks.Total > 0
}
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Deezer URL from song.link...")
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
if !ok || deezerLink.URL == "" {
return "", fmt.Errorf("deezer link not found")
}
deezerURL := deezerLink.URL
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
return deezerURL, nil
}
func GetDeezerISRC(deezerURL string) (string, error) {
var trackID string
if strings.Contains(deezerURL, "/track/") {
parts := strings.Split(deezerURL, "/track/")
if len(parts) > 1 {
trackID = strings.Split(parts[1], "?")[0]
trackID = strings.TrimSpace(trackID)
}
}
if trackID == "" {
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL)
}
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return "", fmt.Errorf("failed to call Deezer API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
}
var deezerTrack struct {
ID int64 `json:"id"`
ISRC string `json:"isrc"`
Title string `json:"title"`
}
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
}
if deezerTrack.ISRC == "" {
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
}
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
return deezerTrack.ISRC, nil
}
+3 -17
View File
@@ -8,7 +8,6 @@ import (
"github.com/mewkiz/flac"
)
// SpectrumData contains frequency spectrum information
type SpectrumData struct {
TimeSlices []TimeSlice `json:"time_slices"`
SampleRate int `json:"sample_rate"`
@@ -17,15 +16,13 @@ type SpectrumData struct {
MaxFreq float64 `json:"max_freq"`
}
// TimeSlice represents spectrum data at a point in time
type TimeSlice struct {
Time float64 `json:"time"`
Magnitudes []float64 `json:"magnitudes"`
}
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
// Open FLAC file
stream, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
@@ -36,7 +33,6 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
sampleRate := int(info.SampleRate)
channels := int(info.NChannels)
// Read audio samples
samples, err := readSamples(stream, channels)
if err != nil {
return nil, fmt.Errorf("failed to read samples: %w", err)
@@ -46,28 +42,23 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
return nil, fmt.Errorf("no audio samples found")
}
// Calculate spectrum
return calculateSpectrum(samples, sampleRate), nil
}
// readSamples reads and decodes audio samples from FLAC stream
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
var allSamples []float64
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues
maxSamples := 10 * 1024 * 1024
// Decode frames
for {
frame, err := stream.ParseNext()
if err != nil {
// End of stream
break
}
// Convert samples to float64 and mix channels to mono
for i := 0; i < frame.Subframes[0].NSamples; i++ {
var sample float64
// Mix all channels to mono by averaging
for ch := 0; ch < channels; ch++ {
sample += float64(frame.Subframes[ch].Samples[i])
}
@@ -75,7 +66,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
allSamples = append(allSamples, sample)
// Limit sample count
if len(allSamples) >= maxSamples {
return allSamples, nil
}
@@ -85,7 +75,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
return allSamples, nil
}
// calculateSpectrum performs FFT analysis on audio samples
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
fftSize := 8192
numTimeSlices := 300
@@ -140,7 +129,6 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
}
}
// applyHannWindow applies Hann window to reduce spectral leakage
func applyHannWindow(samples []float64) []float64 {
n := len(samples)
windowed := make([]float64, n)
@@ -153,7 +141,6 @@ func applyHannWindow(samples []float64) []float64 {
return windowed
}
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
func fft(samples []float64) []complex128 {
n := len(samples)
@@ -165,7 +152,6 @@ func fft(samples []float64) []complex128 {
return fftRecursive(x)
}
// fftRecursive performs recursive FFT
func fftRecursive(x []complex128) []complex128 {
n := len(x)
+1751
View File
File diff suppressed because it is too large Load Diff
+1108 -838
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
//go:build !windows
package backend
import (
"fmt"
"os/exec"
"runtime"
"strings"
)
func GetOSInfo() (string, error) {
osType := runtime.GOOS
arch := runtime.GOARCH
switch osType {
case "darwin":
out, err := exec.Command("sw_vers", "-productVersion").Output()
if err != nil {
return fmt.Sprintf("macOS %s", arch), nil
}
version := strings.TrimSpace(string(out))
return fmt.Sprintf("macOS %s (%s)", version, arch), nil
case "linux":
out, err := exec.Command("cat", "/etc/os-release").Output()
if err == nil {
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "PRETTY_NAME=") {
name := strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
return fmt.Sprintf("%s (%s)", name, arch), nil
}
}
}
return fmt.Sprintf("Linux %s", arch), nil
default:
return fmt.Sprintf("%s %s", osType, arch), nil
}
}
+41
View File
@@ -0,0 +1,41 @@
package backend
import (
"fmt"
"os/exec"
"runtime"
"strings"
"syscall"
)
func GetOSInfo() (string, error) {
arch := runtime.GOARCH
cmd := exec.Command("wmic", "os", "get", "Caption,Version", "/value")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
out, err := cmd.Output()
if err != nil {
cmdVer := exec.Command("cmd", "/c", "ver")
cmdVer.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
outVer, errVer := cmdVer.Output()
if errVer != nil {
return fmt.Sprintf("Windows %s", arch), nil
}
return strings.TrimSpace(string(outVer)), nil
}
lines := strings.Split(string(out), "\n")
var caption, version string
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Caption=") {
caption = strings.TrimPrefix(line, "Caption=")
} else if strings.HasPrefix(line, "Version=") {
version = strings.TrimPrefix(line, "Version=")
}
}
if caption != "" && version != "" {
return fmt.Sprintf("%s (%s, %s)", caption, version, arch), nil
}
return strings.TrimSpace(string(out)), nil
}
+292 -1079
View File
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
package backend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
type SendNowResponse []struct {
FileCode string `json:"file_code"`
}
func UploadToSendNow(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %v", err)
}
defer file.Close()
return uploadToService(filepath.Base(filePath), file)
}
func UploadBytesToSendNow(filename string, data []byte) (string, error) {
return uploadToService(filename, bytes.NewReader(data))
}
func uploadToService(filename string, fileReader io.Reader) (string, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
fields := map[string]string{
"sess_id": "",
"utype": "anon",
"hidden": "",
"enableemail": "",
"link_rcpt": "",
"link_pass": "",
"file_expire_time": "",
"file_expire_unit": "DAY",
"file_max_dl": "1",
"file_public": "1",
"keepalive": "1",
}
for key, val := range fields {
if err := writer.WriteField(key, val); err != nil {
return "", err
}
}
part, err := writer.CreateFormFile("file_0", filename)
if err != nil {
return "", err
}
if _, err := io.Copy(part, fileReader); err != nil {
return "", err
}
writer.Close()
req, err := http.NewRequest("POST", "https://u1112.send.now/cgi-bin/upload.cgi?upload_type=file&utype=anon", body)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
req.Header.Set("Origin", "https://send.now")
req.Header.Set("Referer", "https://send.now/")
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("upload failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("server error %d: %s", resp.StatusCode, string(respBytes))
}
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var result SendNowResponse
if err := json.Unmarshal(respBytes, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %v, raw: %s", err, string(respBytes))
}
if len(result) == 0 || result[0].FileCode == "" {
return "", fmt.Errorf("invalid response format")
}
fileCode := result[0].FileCode
downloadLink := fmt.Sprintf("https://send.now/%s", fileCode)
ext := strings.ToLower(filepath.Ext(filename))
if ext == ".mp4" || ext == ".mov" || ext == ".mkv" || ext == ".webm" || ext == ".avi" {
return fmt.Sprintf("[Video](%s)", downloadLink), nil
}
return fetchDirectImageLink(downloadLink)
}
func fetchDirectImageLink(url string) (string, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
htmlBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
htmlStr := string(htmlBytes)
reFullRes := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["'][^>]*title=["']Open image on new tab["']`)
matchesFull := reFullRes.FindStringSubmatch(htmlStr)
if len(matchesFull) > 1 {
return fmt.Sprintf("![image](%s)", matchesFull[1]), nil
}
reClipboard := regexp.MustCompile(`(?s)data-clipboard-text=['"]<a href="[^"]+".*?><img src="([^"]+)"`)
matches := reClipboard.FindStringSubmatch(htmlStr)
if len(matches) > 1 {
return fmt.Sprintf("![image](%s)", matches[1]), nil
}
reImg := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']*?\.send\.now/i/[^"']+)["']`)
matchesImg := reImg.FindStringSubmatch(htmlStr)
if len(matchesImg) > 1 {
return fmt.Sprintf("![image](%s)", matchesImg[1]), nil
}
reAnchor := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
matchesAnchor := reAnchor.FindStringSubmatch(htmlStr)
if len(matchesAnchor) > 1 {
return fmt.Sprintf("![image](%s)", matchesAnchor[1]), nil
}
reGeneric := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
matchesGeneric := reGeneric.FindAllStringSubmatch(htmlStr, -1)
for _, match := range matchesGeneric {
if len(match) > 1 {
link := match[1]
if !regexp.MustCompile(`(?i)(logo|icon|button|assets)`).MatchString(filepath.Base(link)) {
return fmt.Sprintf("![image](%s)", link), nil
}
}
}
return fmt.Sprintf("[View File](%s)", url), nil
}
+3 -1
View File
@@ -18,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"registries": {
"@lucide-animated": "https://lucide-animated.com/r/{name}.json"
}
}
+20 -21
View File
@@ -1,23 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
},
])
]);
+19 -14
View File
@@ -1,16 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=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=Manrope:wght@300..800&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<title>SpotiFLAC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap"
rel="stylesheet">
<title>SpotiFLAC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+17 -15
View File
@@ -17,38 +17,40 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.556.0",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"next-themes": "^0.4.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17"
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.8",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.1",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.0.0",
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.7"
"typescript-eslint": "^8.53.0",
"vite": "^7.3.1"
}
}
}
+1 -1
View File
@@ -1 +1 @@
58400d5c8f7b03bac8ab784b5e775687
629a5f17426ea4202a25837a341483dd
+1015 -910
View File
File diff suppressed because it is too large Load Diff
+13 -22
View File
@@ -2,32 +2,23 @@ import sharp from 'sharp';
import { readFileSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..', '..');
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
const outputPath = join(rootDir, 'build', 'appicon.png');
async function generateIcon() {
try {
// Ensure build directory exists
mkdirSync(join(rootDir, 'build'), { recursive: true });
// Read SVG
const svgBuffer = readFileSync(svgPath);
// Convert SVG to PNG (1024x1024 for Wails)
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(outputPath);
console.log('✓ Icon generated:', outputPath);
} catch (error) {
console.error('✗ Failed to generate icon:', error.message);
process.exit(1);
}
try {
mkdirSync(join(rootDir, 'build'), { recursive: true });
const svgBuffer = readFileSync(svgPath);
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(outputPath);
console.log('✓ Icon generated:', outputPath);
}
catch (error) {
console.error('✗ Failed to generate icon:', error.message);
process.exit(1);
}
}
generateIcon();
+522 -619
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+18
View File
@@ -0,0 +1,18 @@
export const langColors: Record<string, string> = {
"TypeScript": "#2b7489",
"Go": "#375eab",
"Python": "#3572A5",
"CSS": "#563d7c",
"HTML": "#e44b23",
"JavaScript": "#f1e05a",
"Java": "#b07219",
"C": "#555555",
"C Sharp": "#178600",
"cpp": "#f34b7d",
"Ruby": "#701516",
"PHP": "#4F5D95",
"Swift": "#ffac45",
"Kotlin": "#F18E33",
"Rust": "#dea584",
"Shell": "#89e051"
};
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<!-- Generator: Adobe Illustrator 29.8.3, SVG Export Plug-In . SVG Version: 2.1.1 Build 3) -->
<defs>
<style>
.st0 {
fill: #733e0a;
}
.st1 {
fill: #fdc700;
}
.st2 {
fill: #1ed760;
}
</style>
</defs>
<path class="st2" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0v.1ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1v.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
<path class="st1" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1h0Z"/>
<g>
<path class="st0" d="M70.76,465.35v-77.71h23.4l50.61,59.75-5.66,1.31v-61.06h18.83v77.71h-23.51l-49.52-58.45,4.57-1.74v60.19h-18.72Z"/>
<path class="st0" d="M171.65,465.35v-77.71h76.51v15.78h-55.73v15.24h51.48v15.13h-51.48v15.78h55.73v15.78h-76.51Z"/>
<path class="st0" d="M254.8,465.35l41.47-45.17-2.39,9.25-37.33-41.79h26.34l28.08,32.65-13.17-.44,29.17-32.22h23.51l-39.07,42.01.65-8.82,39.72,44.51h-26.23l-29.82-34.72,14.26-.65-31.56,35.37h-23.62Z"/>
<path class="st0" d="M387.8,465.35v-62.04h-32.76v-15.67h86.2v15.67h-32.65v62.04h-20.79Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: #2dc261;
fill-rule: evenodd;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
<g>
<g id="Layer_1">
<g id="SVGRepo_iconCarrier">
<g id="Page-1" sketch:type="MSPage">
<g id="Icon-Set-Filled" sketch:type="MSLayerGroup">
<path id="arrow-right-circle" class="cls-1" d="M350.1,268.7l-81.5,81.5c-5.6,5.6-14.7,5.6-20.4,0-5.6-5.6-5.6-14.8,0-20.5l59.4-59.3h-152.5c-8,0-14.4-6.5-14.4-14.4s6.4-14.4,14.4-14.4h152.5l-59.4-59.3c-5.6-5.6-5.6-14.7,0-20.5,5.6-5.6,14.7-5.6,20.4,0l81.5,81.5c3.5,3.5,4.5,8.2,3.7,12.7.8,4.5-.3,9.2-3.7,12.7h0ZM256,25.6c-127.3,0-230.4,103.1-230.4,230.4s103.2,230.4,230.4,230.4,230.4-103.1,230.4-230.4S383.3,25.6,256,25.6h0Z" sketch:type="MSShapeGroup"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: #733e0a;
}
.cls-2 {
fill: #fdc700;
}
.cls-3 {
fill: #1ed760;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
<g>
<g id="Layer_1">
<g>
<g id="_1818452274576">
<g id="SVGRepo_iconCarrier">
<path class="cls-3" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
</g>
</g>
<path class="cls-2" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1Z"/>
<g>
<path class="cls-1" d="M113.6,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v35h17.5v-35c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.7,4.4-2.2,5.9-1.5,1.5-3.5,2.2-5.9,2.2s-4.4-.8-5.9-2.3c-1.5-1.5-2.3-3.5-2.3-5.8v-39.5h-17.5v39.5c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
<path class="cls-1" d="M175.9,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
<path class="cls-1" d="M200.4,434c-2,0-3.7-.7-5.2-2.2-1.5-1.4-2.2-3.2-2.2-5.3s.7-3.8,2.2-5.2c1.4-1.4,3.2-2.2,5.2-2.2h19.5c2,0,3.8.7,5.2,2.2s2.2,3.2,2.2,5.2-.7,3.8-2.2,5.3-3.2,2.2-5.2,2.2h-19.5Z"/>
<path class="cls-1" d="M250.3,477.2c-1.4,1.4-3.4,2.1-6,2.1s-4.6-.7-6-2.1c-1.4-1.4-2.1-3.4-2.1-6v-88.4c0-2.6.7-4.6,2.1-6,1.4-1.4,3.4-2.1,6-2.1h16c8.4,0,14.5,2,18.4,5.9,3.9,3.9,5.8,9.9,5.8,18v6.4c0,10.7-3.6,17.6-10.7,20.5v.3c3.9,1.2,6.7,3.6,8.4,7.2s2.5,8.5,2.5,14.7v16.1c0,2.5.2,4.5.6,6.1.4,1.5.6,2.8.6,4.1,0,3.6-2.6,5.4-7.7,5.4s-5.9-1-7.5-2.9c-1.6-2-2.4-5.2-2.4-9.8v-19.8c0-4.8-.8-8.1-2.3-10-1.5-1.9-4.2-2.8-7.9-2.8h-5.6v37.2c0,2.6-.7,4.6-2.1,6ZM252.4,419.1h5.9c3.2,0,5.7-.8,7.3-2.5s2.5-4.5,2.5-8.5v-8c0-3.7-.7-6.4-2-8.1s-3.4-2.6-6.3-2.6h-7.5v29.7Z"/>
<path class="cls-1" d="M304,478.4c-2.4,0-4.3-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.9v-87.5c0-2.4.8-4.3,2.3-5.9s3.5-2.3,5.9-2.3h29.8c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-21.7v27.4h15.9c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-15.9v31.9h21.7c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-29.8Z"/>
<path class="cls-1" d="M371.2,479.9c-7.9,0-13.8-2.2-17.9-6.6-4.1-4.4-6.1-10.6-6.1-18.6s.7-4.3,2-5.7c1.4-1.4,3.3-2.1,5.7-2.1s4.2.7,5.6,2c1.4,1.3,2.1,3.4,2.1,6.2,0,6.8,2.8,10.1,8.5,10.1s8.5-3.5,8.5-10.4-1-8.1-3-11.4c-2-3.3-5.6-7.3-11-12-6.8-5.9-11.5-11.3-14.1-16.1-2.7-4.8-4-10.2-4-16.2s2.1-14.6,6.3-19c4.2-4.5,10.2-6.7,18.1-6.7s13.5,2.2,17.6,6.6c4.1,4.4,6.2,10.1,6.2,17s-2.6,7.8-7.7,7.8-4.4-.7-5.7-2.2-2-3.3-2-5.6-.7-4.9-2.1-6.4c-1.4-1.5-3.4-2.3-6-2.3-5.5,0-8.2,3.3-8.2,9.9s1,7.3,3.1,10.5c2.1,3.2,5.7,7.2,11,11.9,6.8,6,11.4,11.4,14,16.3,2.6,4.9,3.9,10.5,3.9,17s-2.1,15-6.3,19.5c-4.2,4.6-10.3,6.8-18.3,6.8Z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#00bc7d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-music-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /><path d="M9 17v-13h10v8" /><path d="M9 8h10" /><path d="M19 16v6" /><path d="M22 19l-3 3l-3 -3" /></svg>

After

Width:  |  Height:  |  Size: 448 B

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
id="Layer_1" width="512px" height="512px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;"
xml:space="preserve">
<g fill="#1da0f1">
<polygon
points="12.153992,10.729553 8.088684,5.041199 5.92041,5.041199 10.956299,12.087097 11.59021,12.97345 15.900635,19.009583 18.068909,19.009583 12.785217,11.615906 " />
<path
d="M21.15979,1H2.84021C1.823853,1,1,1.823853,1,2.84021v18.31958C1,22.176147,1.823853,23,2.84021,23h18.31958 C22.176147,23,23,22.176147,23,21.15979V2.84021C23,1.823853,22.176147,1,21.15979,1z M15.235352,20l-4.362549-6.213013 L5.411438,20H4l6.246887-7.104675L4,4h4.764648l4.130127,5.881958L18.06958,4h1.411377l-5.95697,6.775635L20,20H15.235352z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+438
View File
@@ -0,0 +1,438 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
import { GetOSInfo } from "../../wailsjs/go/main/App";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks } from "lucide-react";
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XProIcon from "@/assets/x-pro.webp";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import { langColors } from "@/assets/github-lang-colors";
import { ScrollArea } from "@/components/ui/scroll-area";
import { DragDropMedia } from "./DragDropTextarea";
interface AboutPageProps {
version: string;
}
export function AboutPage({ version }: AboutPageProps) {
const [os, setOs] = useState("Unknown");
const [location, setLocation] = useState("Unknown");
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects">("bug_report");
const [bugType, setBugType] = useState("Track");
const [problem, setProblem] = useState("");
const [spotifyUrl, setSpotifyUrl] = useState("");
const [bugContext, setBugContext] = useState("");
const [featureDesc, setFeatureDesc] = useState("");
const [useCase, setUseCase] = useState("");
const [featureContext, setFeatureContext] = useState("");
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
useEffect(() => {
const fetchOS = async () => {
try {
const info = await GetOSInfo();
setOs(info);
}
catch (err) {
const userAgent = window.navigator.userAgent;
if (userAgent.indexOf("Win") !== -1)
setOs("Windows");
else if (userAgent.indexOf("Mac") !== -1)
setOs("macOS");
else if (userAgent.indexOf("Linux") !== -1)
setOs("Linux");
}
};
fetchOS();
const fetchLocation = async () => {
try {
const response = await fetch('https://ipapi.co/json/');
if (response.ok) {
const data = await response.json();
const city = data.city || '';
const region = data.region || '';
const country = data.country_name || '';
const parts = [city, region, country].filter(Boolean);
setLocation(parts.join(', ') || 'Unknown');
}
else {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
setLocation(timezone);
}
}
catch (err) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
setLocation(timezone);
}
};
fetchLocation();
const fetchRepoStats = async () => {
const CACHE_KEY = 'github_repo_stats';
const CACHE_DURATION = 1000 * 60 * 60;
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp < CACHE_DURATION) {
setRepoStats(data);
return;
}
}
catch (err) {
console.error('Failed to parse cache:', err);
}
}
const repos = [
{ name: 'SpotiDownloader', owner: 'afkarxyz' },
{ name: 'SpotiFLAC-Next', owner: 'spotiverse' },
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' }
];
const stats: Record<string, any> = {};
for (const repo of repos) {
try {
const [repoRes, releasesRes, langsRes] = await Promise.all([
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`),
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`),
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`)
]);
if (repoRes.status === 403) {
if (cached) {
const { data } = JSON.parse(cached);
setRepoStats(data);
}
return;
}
if (repoRes.ok && releasesRes.ok && langsRes.ok) {
const repoData = await repoRes.json();
const releases = await releasesRes.json();
const languages = await langsRes.json();
let totalDownloads = 0;
let latestDownloads = 0;
if (releases.length > 0) {
latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
totalDownloads = releases.reduce((sum: number, release: any) => {
return sum + (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0);
}, 0);
}
const topLangs = Object.entries(languages)
.sort(([, a]: any, [, b]: any) => b - a)
.slice(0, 4)
.map(([lang]) => lang);
stats[repo.name] = {
stars: repoData.stargazers_count,
forks: repoData.forks_count,
createdAt: repoData.created_at,
totalDownloads,
latestDownloads,
languages: topLangs
};
}
}
catch (err) {
console.error(`Failed to fetch stats for ${repo.name}:`, err);
if (cached) {
const { data } = JSON.parse(cached);
setRepoStats(data);
return;
}
}
}
setRepoStats(stats);
localStorage.setItem(CACHE_KEY, JSON.stringify({ data: stats, timestamp: Date.now() }));
};
fetchRepoStats();
}, []);
const faqs = [
{
q: "Is this software free?",
a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection."
},
{
q: "Can using this software get my Spotify account suspended or banned?",
a: "No. This software has no connection to your Spotify account. Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication."
},
{
q: "Where does the audio come from?",
a: "The audio is fetched using third-party APIs."
},
{
q: "Why does metadata fetching sometimes fail?",
a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit."
},
{
q: "Why does Windows Defender or antivirus flag or delete the file?",
a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source."
}
];
const formatTimeAgo = (dateString: string): string => {
const now = new Date();
const updated = new Date(dateString);
const diffMs = now.getTime() - updated.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffMonths = Math.floor(diffDays / 30);
if (diffDays === 0)
return 'today';
if (diffDays === 1)
return '1d';
if (diffDays < 30)
return `${diffDays}d`;
if (diffMonths === 1)
return '1mo';
if (diffMonths < 12)
return `${diffMonths}mo`;
const diffYears = Math.floor(diffMonths / 12);
return `${diffYears}y`;
};
const formatNumber = (num: number): string => {
if (num >= 1000) {
return num.toLocaleString();
}
return num.toString();
};
const getLangColor = (lang: string): string => {
return langColors[lang] || '#858585';
};
const handleSubmit = () => {
const title = activeTab === "bug_report"
? `[Bug Report] ${problem.substring(0, 50)}${problem.length > 50 ? "..." : ""}`
: `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`;
let bodyContent = "";
if (activeTab === "bug_report") {
const contextContent = bugContext.trim() ? bugContext.trim() : "Type here or send screenshot/recording";
bodyContent = `### [Bug Report]
#### Problem
${problem || "Type here"}
#### Type
${bugType}
#### Spotify URL
${spotifyUrl || "Type here"}
#### Additional Context
${contextContent}
#### Environment
- SpotiFLAC Version: ${version}
- OS: ${os}
- Location: ${location}`;
}
else {
const contextContent = featureContext.trim() ? featureContext.trim() : "Type here or send screenshot/recording";
bodyContent = `### [Feature Request]
#### Description
${featureDesc || "Type here"}
#### Use Case
${useCase || "Type here"}
#### Additional Context
${contextContent}`;
}
const params = new URLSearchParams({
title: title,
body: bodyContent
});
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
openExternal(url);
};
return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}>
<div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">About</h2>
</div>
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none">
<Bug className="h-4 w-4"/>
Bug Report
</Button>
<Button variant={activeTab === "feature_request" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("feature_request")} className="rounded-b-none">
<Lightbulb className="h-4 w-4"/>
Feature Request
</Button>
<Button variant={activeTab === "faq" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("faq")} className="rounded-b-none">
<CircleHelp className="h-4 w-4"/>
FAQ
</Button>
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
<Blocks className="h-4 w-4"/>
Other Projects
</Button>
</div>
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
{activeTab === "bug_report" && (<div className="flex flex-col">
<div className="space-y-4 pt-4 flex flex-col">
<div className="mt-4 pr-2">
<div className="grid md:grid-cols-3 gap-6">
<div className="space-y-2 flex flex-col">
<Label>Problem</Label>
<Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)}/>
</div>
<div className="space-y-2 flex flex-col">
<Label>Additional Context</Label>
<DragDropMedia className="min-h-[14rem]" value={bugContext} onChange={setBugContext}/>
</div>
<div className="space-y-4 flex flex-col">
<div className="space-y-2">
<Label>Type</Label>
<ToggleGroup type="single" value={bugType} onValueChange={(val) => {
if (val)
setBugType(val);
}} className="justify-start w-full cursor-pointer">
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">Track</ToggleGroupItem>
<ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">Album</ToggleGroupItem>
<ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">Playlist</ToggleGroupItem>
<ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">Artist</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-2">
<Label>Spotify URL</Label>
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)}/>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-center pt-4 shrink-0">
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
</Button>
</div>
</div>)}
{activeTab === "feature_request" && (<div className="flex flex-col">
<div className="space-y-4 pt-4 flex flex-col">
<div className="mt-4 pr-2">
<div className="grid md:grid-cols-3 gap-6">
<div className="space-y-2 flex flex-col">
<Label>Description</Label>
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)}/>
</div>
<div className="space-y-2 flex-col">
<Label>Use Case</Label>
<Textarea className="h-56 resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={e => setUseCase(e.target.value)}/>
</div>
<div className="space-y-2 flex-col">
<Label>Additional Context</Label>
<DragDropMedia className="min-h-[14rem]" value={featureContext} onChange={setFeatureContext}/>
</div>
</div>
</div>
</div>
<div className="flex justify-center pt-4 shrink-0">
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
</Button>
</div>
</div>)}
{activeTab === "faq" && (<ScrollArea className="h-full">
<div className="p-1 pr-4">
<Card>
<CardHeader>
<CardTitle>Frequently Asked Questions</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
</div>))}
</CardContent>
</Card>
</div>
</ScrollArea>)}
{activeTab === "projects" && (<div className="p-1 pr-2">
<div className="grid gap-2 grid-cols-4">
<div className="flex flex-col gap-2 h-full">
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.cc/")}>
<CardHeader>
<CardTitle>Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex gap-3 pt-2">
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/> SpotubeDL</CardTitle>
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
</CardHeader>
</Card>
</div>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader"/> SpotiDownloader</CardTitle>
<CardDescription>Get Spotify tracks in MP3 and FLAC via spotidownloader.com</CardDescription>
</CardHeader>
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats['SpotiDownloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiDownloader'].stars)}</span>
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiDownloader'].forks}</span>
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)}</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)}</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotiFLACNextIcon} className="h-5 w-5" alt="SpotiFLAC Next"/> SpotiFLAC Next</CardTitle>
<CardDescription>Get Spotify tracks in Hi-Res lossless FLACs no account required.</CardDescription>
</CardHeader>
{repoStats['SpotiFLAC-Next'] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats['SpotiFLAC-Next'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiFLAC-Next'].stars)}</span>
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiFLAC-Next'].forks}</span>
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiFLAC-Next'].createdAt)}</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiFLAC-Next'].totalDownloads)}</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiFLAC-Next'].latestDownloads)}</span>
</div>
</CardContent>)}
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader"/> Twitter/X Media Batch Downloader</CardTitle>
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
</CardHeader>
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)}</span>
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)}</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)}</span>
</div>
</CardContent>)}
</Card>
</div>
</div>)}
</div>
</div>);
}
+105 -209
View File
@@ -1,251 +1,147 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
name: string;
artists: string;
images: string;
release_date: string;
total_tracks: number;
artist_id?: string;
artist_url?: string;
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
onTrackClick?: (track: TrackMetadata) => void;
albumInfo: {
name: string;
artists: string;
images: string;
release_date: string;
total_tracks: number;
artist_id?: string;
artist_url?: string;
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => 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,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
onArtistClick,
onTrackClick,
}: AlbumInfoProps) {
return (
<div className="space-y-6">
<Card>
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, onBack, }: AlbumInfoProps) {
return (<div className="space-y-6">
<Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{albumInfo.images && (
<img
src={albumInfo.images}
alt={albumInfo.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Album</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm">
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (
<span
className="font-medium cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
id: albumInfo.artist_id!,
name: albumInfo.artists,
external_urls: albumInfo.artist_url!,
})
}
>
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onArtistClick({
id: albumInfo.artist_id!,
name: albumInfo.artists,
external_urls: albumInfo.artist_url!,
})}>
{albumInfo.artists}
</span>
) : (
<span className="font-medium">{albumInfo.artists}</span>
)}
</span>) : (<span className="font-medium">{albumInfo.artists}</span>)}
<span></span>
<span>{albumInfo.release_date}</span>
<span></span>
<span>{albumInfo.total_tracks} songs</span>
<span>
{albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"}
</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</Button>
{selectedTracks.length > 0 && (
<Button
onClick={onDownloadSelected}
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllCovers && (
<Tooltip>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>
)}
</Button>)}
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={true}
folderName={albumInfo.name}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onTrackClick={onTrackClick}
/>
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={true} folderName={albumInfo.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>
</div>
);
</div>);
}
+521 -258
View File
@@ -1,294 +1,557 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck, XCircle, Filter } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { useState, useMemo } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ArtistInfoProps {
artistInfo: {
name: string;
images: string;
followers: number;
genres: string[];
};
albumList: Array<{
id: string;
name: string;
images: string;
release_date: string;
album_type: string;
external_urls: string;
}>;
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => 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;
artistInfo: {
name: string;
images: string;
header?: string;
gallery?: string[];
followers: number;
genres: string[];
biography?: string;
verified?: boolean;
listeners?: number;
rank?: number;
};
albumList: Array<{
id: string;
name: string;
images: string;
release_date: string;
album_type: string;
external_urls: string;
total_tracks?: number;
}>;
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => 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;
onBack?: () => 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,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onAlbumClick,
onArtistClick,
onPageChange,
onTrackClick,
}: ArtistInfoProps) {
return (
<div className="space-y-6">
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{artistInfo.images && (
<img
src={artistInfo.images}
alt={artistInfo.name}
className="w-48 h-48 rounded-full shadow-lg object-cover"
/>
)}
<div className="flex-1 space-y-2">
<p className="text-sm font-medium">Artist</p>
<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>
<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>
</>
)}
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, onBack, }: ArtistInfoProps) {
const [downloadingHeader, setDownloadingHeader] = useState(false);
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
const filteredAlbumGroups = useMemo(() => {
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
const albumGroups = trackList.reduce((acc, track) => {
if (!track.album_name)
return acc;
if (!acc[track.album_name]) {
acc[track.album_name] = {
count: 0,
tracks: [],
type: albumTypeMap.get(track.album_name) || "unknown"
};
}
acc[track.album_name].count++;
acc[track.album_name].tracks.push(track);
return acc;
}, {} as Record<string, {
count: number;
tracks: TrackMetadata[];
type: string;
}>);
return Object.entries(albumGroups).sort((a, b) => {
const dateA = a[1].tracks[0]?.release_date || "";
const dateB = b[1].tracks[0]?.release_date || "";
return dateB.localeCompare(dateA);
});
}, [trackList, albumList]);
const handleDownloadHeader = async () => {
if (!artistInfo.header)
return;
setDownloadingHeader(true);
try {
const settings = getSettings();
const response = await downloadHeader({
header_url: artistInfo.header,
artist_name: artistInfo.name,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info("Header already exists");
}
else {
toast.success("Header downloaded successfully");
}
}
else {
toast.error(response.error || "Failed to download header");
}
}
catch (error) {
toast.error(`Error downloading header: ${error}`);
}
finally {
setDownloadingHeader(false);
}
};
const handleDownloadAvatar = async () => {
if (!artistInfo.images)
return;
setDownloadingAvatar(true);
try {
const settings = getSettings();
const response = await downloadAvatar({
avatar_url: artistInfo.images,
artist_name: artistInfo.name,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info("Avatar already exists");
}
else {
toast.success("Avatar downloaded successfully");
}
}
else {
toast.error(response.error || "Failed to download avatar");
}
}
catch (error) {
toast.error(`Error downloading avatar: ${error}`);
}
finally {
setDownloadingAvatar(false);
}
};
const handleDownloadGalleryImage = async (imageUrl: string, index: number) => {
setDownloadingGalleryIndex(index);
try {
const settings = getSettings();
const response = await downloadGalleryImage({
image_url: imageUrl,
artist_name: artistInfo.name,
image_index: index,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info(`Gallery image ${index + 1} already exists`);
}
else {
toast.success(`Gallery image ${index + 1} downloaded successfully`);
}
}
else {
toast.error(response.error || `Failed to download gallery image ${index + 1}`);
}
}
catch (error) {
toast.error(`Error downloading gallery image ${index + 1}: ${error}`);
}
finally {
setDownloadingGalleryIndex(null);
}
};
const handleDownloadAllGallery = async () => {
if (!artistInfo.gallery || artistInfo.gallery.length === 0)
return;
setDownloadingAllGallery(true);
try {
const settings = getSettings();
let successCount = 0;
let existsCount = 0;
let failCount = 0;
for (let index = 0; index < artistInfo.gallery.length; index++) {
const imageUrl = artistInfo.gallery[index];
try {
const response = await downloadGalleryImage({
image_url: imageUrl,
artist_name: artistInfo.name,
image_index: index,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
existsCount++;
}
else {
successCount++;
}
}
else {
failCount++;
}
}
catch (error) {
failCount++;
}
}
if (failCount === 0) {
if (existsCount > 0 && successCount > 0) {
toast.success(`${successCount} images downloaded, ${existsCount} already existed`);
}
else if (existsCount > 0) {
toast.info(`All ${existsCount} images already exist`);
}
else {
toast.success(`All ${successCount} gallery images downloaded successfully`);
}
}
else {
toast.error(`${failCount} images failed to download`);
}
}
catch (error) {
toast.error(`Error downloading gallery images: ${error}`);
}
finally {
setDownloadingAllGallery(false);
}
};
const hasGallery = artistInfo.gallery && artistInfo.gallery.length > 0;
return (<div className="space-y-6">
<Card className="overflow-hidden p-0 relative">
{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"/>
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<div className="absolute bottom-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-white fill-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">
{artistInfo.rank && (<>
<span>#{artistInfo.rank} rank</span>
<span></span>
</>)}
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
{artistInfo.listeners && (<>
<span></span>
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
</>)}
</div>
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
<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>
</CardContent>
</>) : (<CardContent className="px-6 py-6">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<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-white fill-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">
{artistInfo.rank && (<>
<span>#{artistInfo.rank} rank</span>
<span></span>
</>)}
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
{artistInfo.listeners && (<>
<span></span>
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
</>)}
</div>
<div className="flex items-center gap-2 text-sm flex-wrap">
<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>
</CardContent>)}
</Card>
{albumList.length > 0 && (
<div className="space-y-4">
<h3 className="text-2xl font-bold">Discography</h3>
<div className="border-b">
<div className="flex gap-6">
<button onClick={() => setActiveTab("albums")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "albums" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Albums
</button>
<button onClick={() => setActiveTab("tracks")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "tracks" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
All Tracks
</button>
{hasGallery && (<button onClick={() => setActiveTab("gallery")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "gallery" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Gallery
</button>)}
</div>
</div>
{activeTab === "gallery" && hasGallery && (<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">
{albumList.map((album) => (
<div
key={album.id}
className="group cursor-pointer"
onClick={() =>
onAlbumClick({
{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>)}
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
<h3 className="text-2xl font-bold">Discography</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
id: album.id,
name: album.name,
external_urls: album.external_urls,
})
}
>
<div className="relative mb-4">
{album.images && (
<img
src={album.images}
alt={album.name}
className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"
/>
)}
})}>
<div className="relative mb-2">
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
<div className="absolute bottom-2 right-2">
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur-[2px]">
{album.album_type}
</span>
</div>
</div>
<h4 className="font-semibold truncate">{album.name}</h4>
<p className="text-sm text-muted-foreground">
{album.release_date?.split("-")[0]} {album.album_type}
</p>
</div>
))}
<h4 className="font-semibold truncate text-sm">{album.name}</h4>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<span>{album.release_date?.split("-")[0]}</span>
{album.total_tracks && (<>
<span></span>
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
</>)}
</div>
</div>))}
</div>
</div>
)}
</div>)}
{trackList.length > 0 && (
<div className="space-y-4">
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">Popular Tracks</h3>
<h3 className="text-2xl font-bold">All Tracks</h3>
<div className="flex gap-2 flex-wrap">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4"/>
Filter Albums
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Albums</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-4">
<div className="space-y-4">
{filteredAlbumGroups.map(([albumName, data]) => {
const tracksWithIsrc = data.tracks.filter(t => t.isrc);
const isSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every(t => selectedTracks.includes(t.isrc!));
return (<div key={albumName} className="flex items-start space-x-3 p-2 hover:bg-muted/50 rounded-md transition-colors">
<Checkbox id={`album-select-${albumName}`} checked={isSelected} onCheckedChange={() => onToggleSelectAll(data.tracks)} className="mt-1"/>
<div className="grid gap-1.5 leading-none flex-1">
<label htmlFor={`album-select-${albumName}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer">
{albumName}
</label>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="capitalize bg-muted px-1.5 py-0.5 rounded text-[10px] font-semibold border">
{data.type}
</span>
<span></span>
<span>{data.count} tracks</span>
<span></span>
<span>{data.tracks[0]?.release_date?.split('-')[0] || 'Unknown Year'}</span>
</div>
</div>
</div>);
})}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</Button>
{selectedTracks.length > 0 && (
<Button
onClick={onDownloadSelected}
size="sm"
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllCovers && (
<Tooltip>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
size="sm"
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
<Button onClick={onDownloadAllLyrics} size="sm" variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} size="sm" variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4" />
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>
)}
</Button>)}
</div>
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={false}
folderName={artistInfo.name}
isArtistDiscography={true}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick}
onTrackClick={onTrackClick}
/>
</div>
)}
</div>
);
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>)}
</div>);
}
+87 -156
View File
@@ -1,194 +1,125 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
import {
Activity,
Waves,
Radio,
TrendingUp,
FileAudio,
Clock,
Gauge,
HardDrive
} from "lucide-react";
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
import type { AnalysisResult } from "@/types/api";
interface AudioAnalysisProps {
result: AnalysisResult | null;
analyzing: boolean;
onAnalyze?: () => void;
showAnalyzeButton?: boolean;
result: AnalysisResult | null;
analyzing: boolean;
onAnalyze?: () => void;
showAnalyzeButton?: boolean;
filePath?: string;
}
export function AudioAnalysis({
result,
analyzing,
onAnalyze,
showAnalyzeButton = true
}: AudioAnalysisProps) {
if (analyzing) {
return (
<Card>
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
if (analyzing) {
return (<Card>
<CardContent className="px-6">
<div className="flex items-center justify-center py-8 gap-3">
<Spinner />
<span className="text-muted-foreground">Analyzing audio quality...</span>
</div>
</CardContent>
</Card>
);
}
if (!result && showAnalyzeButton) {
return (
<Card>
</Card>);
}
if (!result && showAnalyzeButton) {
return (<Card>
<CardContent className="px-6">
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Activity className="h-12 w-12 text-muted-foreground/50" />
<Activity className="h-12 w-12 text-primary"/>
<div className="text-center space-y-2">
<p className="font-medium">Audio Quality Analysis</p>
<p className="text-sm text-muted-foreground">
Verify the true lossless quality of downloaded files
</p>
</div>
{onAnalyze && (
<Button onClick={onAnalyze}>
<Activity className="h-4 w-4" />
{onAnalyze && (<Button onClick={onAnalyze}>
<Activity className="h-4 w-4"/>
Analyze Audio
</Button>
)}
</Button>)}
</div>
</CardContent>
</Card>
);
}
if (!result) {
return null;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatNumber = (num: number) => {
return num.toFixed(2);
};
// Calculate Nyquist frequency (half of sample rate)
const nyquistFreq = result.sample_rate / 2;
// Calculate approximate data size (uncompressed PCM)
// Formula: sample_rate * channels * (bits_per_sample / 8) * duration
const dataSizeBytes = result.sample_rate * result.channels * (result.bits_per_sample / 8) * result.duration;
const dataSizeMB = dataSizeBytes / (1024 * 1024);
const formatDataSize = (mb: number) => {
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
</Card>);
}
return `${mb.toFixed(2)} MB`;
};
return (
<Card>
if (!result) {
return null;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatNumber = (num: number) => {
return num.toFixed(2);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
const nyquistFreq = result.sample_rate / 2;
return (<Card className="gap-2">
<CardHeader>
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Audio Quality Analysis
</CardTitle>
<CardDescription>
Technical analysis of audio file properties
</CardDescription>
</div>
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
</CardHeader>
<CardContent className="px-6 space-y-6">
{/* Technical Specifications */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Radio className="h-3 w-3" />
Sample Rate
</div>
<p className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</p>
<CardContent className="space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Sample Rate:</span>
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileAudio className="h-3 w-3" />
Bit Depth
</div>
<p className="font-semibold">{result.bit_depth}</p>
<div className="flex items-center gap-1">
<FileAudio className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Bit Depth:</span>
<span className="font-semibold">{result.bit_depth}</span>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Waves className="h-3 w-3" />
Channels
</div>
<p className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}</p>
<div className="flex items-center gap-1">
<Waves className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Channels:</span>
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Duration
</div>
<p className="font-semibold">{formatDuration(result.duration)}</p>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold">{formatDuration(result.duration)}</span>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Gauge className="h-3 w-3" />
Nyquist Frequency
</div>
<p className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3 w-3" />
Data Size
</div>
<p className="font-semibold">{formatDataSize(dataSizeMB)}</p>
<div className="flex items-center gap-1">
<Gauge className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Nyquist:</span>
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
</div>
{result.file_size > 0 && (<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Size:</span>
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
</div>)}
</div>
{/* Dynamic Range Analysis */}
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="flex items-center gap-2 text-sm font-medium">
<TrendingUp className="h-4 w-4" />
Dynamic Range Analysis
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
<div className="flex items-center gap-1">
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Dynamic Range:</span>
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Dynamic Range</p>
<p className="font-semibold">{formatNumber(result.dynamic_range)} dB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Peak Level</p>
<p className="font-semibold">{formatNumber(result.peak_amplitude)} dB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">RMS Level</p>
<p className="font-semibold">{formatNumber(result.rms_level)} dB</p>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Peak:</span>
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">RMS:</span>
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
</div>
<div className="flex items-center gap-1 ml-auto">
<span className="text-muted-foreground">Samples:</span>
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
</div>
</div>
{/* Technical Info Footer */}
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
Total Samples: {result.total_samples.toLocaleString()}
</p>
</div>
</CardContent>
</Card>
);
</Card>);
}
+88 -134
View File
@@ -1,159 +1,113 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Activity, Upload, ArrowLeft } from "lucide-react";
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps {
onBack?: () => void;
onBack?: () => void;
}
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
setSelectedFilePath(filePath);
await analyzeFile(filePath);
}
} catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
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;
}
setSelectedFilePath(filePath);
await analyzeFile(filePath);
},
[analyzeFile]
);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
await analyzeFile(filePath);
}
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
}, [handleFileDrop]);
const handleAnalyzeAnother = () => {
clearResult();
setSelectedFilePath("");
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
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 handleAnalyzeAnother = () => {
clearResult();
};
return (<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5"/>
</Button>)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div>
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
<Trash2 className="h-4 w-4"/>
Clear
</Button>)}
</div>
{/* File Selection */}
{!result && !analyzing && (
<div
className={`flex flex-col items-center justify-center py-24 border-2 border-dashed rounded-lg transition-colors ${
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
<Activity
className={`h-20 w-20 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`}
/>
<h3 className="text-xl font-medium mb-2">Analyze FLAC Audio Quality</h3>
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}} onDragLeave={(e) => {
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">
<Upload className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your FLAC file here"
: "Drag and drop a FLAC file here, or click the button below to select"}
? "Drop your FLAC file here"
: "Drag and drop a FLAC file here, or click the button below to select"}
</p>
<Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" />
<Upload className="h-5 w-5"/>
Select FLAC File
</Button>
</div>
)}
</div>)}
{/* Loading State */}
{analyzing && !result && (
<div className="flex flex-col items-center justify-center py-16">
{analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>
)}
</div>)}
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* File Info */}
<div className="p-3 bg-muted/30 rounded-lg">
<p className="text-xs text-muted-foreground">Analyzing file:</p>
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
</div>
{result && (<div className="space-y-4">
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
{/* Spectrum Visualization */}
<SpectrumVisualization
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
{/* Detailed Analysis */}
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} />
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button onClick={handleAnalyzeAnother} variant="outline">
<Upload className="h-4 w-4" />
Analyze Another File
</Button>
</div>
</div>
)}
</div>
);
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
</div>)}
</div>);
}
@@ -0,0 +1,429 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioFile {
path: string;
name: string;
format: string;
size: number;
status: "pending" | "converting" | "success" | "error";
error?: string;
outputPath?: string;
}
function formatFileSize(bytes: number): string {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const BITRATE_OPTIONS = [
{ value: "320k", label: "320k" },
{ value: "256k", label: "256k" },
{ value: "192k", label: "192k" },
{ value: "128k", label: "128k" },
];
const M4A_CODEC_OPTIONS = [
{ value: "aac", label: "AAC" },
{ value: "alac", label: "ALAC" },
];
const STORAGE_KEY = "spotiflac_audio_converter_state";
export function AudioConverterPage() {
const [files, setFiles] = useState<AudioFile[]>(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) {
return parsed.files;
}
}
}
catch (err) {
console.error("Failed to load saved state:", err);
}
return [];
});
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
return parsed.outputFormat;
}
}
}
catch (err) {
}
return "mp3";
});
const [bitrate, setBitrate] = useState(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.bitrate) {
return parsed.bitrate;
}
}
}
catch (err) {
}
return "320k";
});
const [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") {
return parsed.m4aCodec;
}
}
}
catch (err) {
}
return "aac";
});
const [converting, setConverting] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const saveState = useCallback((stateToSave: {
files: AudioFile[];
outputFormat: "mp3" | "m4a";
bitrate: string;
m4aCodec: "aac" | "alac";
}) => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
}
catch (err) {
console.error("Failed to save state:", err);
}
}, []);
useEffect(() => {
saveState({ files, outputFormat, bitrate, m4aCodec });
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
useEffect(() => {
if (files.length === 0)
return;
const allMP3 = files.every((f) => f.format === "mp3");
if (allMP3 && outputFormat !== "m4a") {
setOutputFormat("m4a");
}
const hasFlac = files.some((f) => f.format === "flac");
if (!hasFlac && m4aCodec === "alac") {
setM4aCodec("aac");
}
}, [files, outputFormat, m4aCodec]);
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
const hasFlacFiles = files.some((f) => f.format === "flac");
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const handleSelectFiles = async () => {
try {
const selectedFiles = await SelectAudioFiles();
if (selectedFiles && selectedFiles.length > 0) {
addFiles(selectedFiles);
}
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select files",
});
}
};
const addFiles = useCallback(async (paths: string[]) => {
const validExtensions = [".mp3", ".flac"];
const m4aFiles = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return ext === ".m4a";
});
if (m4aFiles.length > 0) {
toast.error("M4A files not supported", {
description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
});
}
const GetFileSizes = (files: string[]): Promise<Record<string, number>> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files);
const validPaths = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return validExtensions.includes(ext);
});
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
setFiles((prev) => {
const newFiles: AudioFile[] = validPaths
.filter((path) => !prev.some((f) => f.path === path))
.map((path) => {
const name = path.split(/[/\\]/).pop() || path;
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
return {
path,
name,
format: ext,
size: fileSizes[path] || 0,
status: "pending" as const,
};
});
if (newFiles.length > 0) {
if (paths.length > newFiles.length) {
const skipped = paths.length - newFiles.length;
toast.info("Some files skipped", {
description: `${skipped} file(s) were skipped (unsupported format or already added)`,
});
}
return [...prev, ...newFiles];
}
if (paths.length > 0 && m4aFiles.length === 0) {
toast.info("No new files added", {
description: "All files were already added or have unsupported format",
});
}
return prev;
});
}, []);
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0)
return;
addFiles(paths);
}, [addFiles]);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const removeFile = (path: string) => {
setFiles((prev) => prev.filter((f) => f.path !== path));
};
const clearFiles = () => {
setFiles([]);
};
const handleConvert = async () => {
if (files.length === 0) {
toast.error("No files selected", {
description: "Please add audio files to convert",
});
return;
}
setConverting(true);
try {
const inputPaths = files.map((f) => f.path);
setFiles((prev) => prev.map((f) => {
if (inputPaths.includes(f.path)) {
return { ...f, status: "converting" as const, error: undefined };
}
return f;
}));
const results = await ConvertAudio({
input_files: inputPaths,
output_format: outputFormat,
bitrate: bitrate,
codec: outputFormat === "m4a" ? m4aCodec : "",
});
setFiles((prev) => prev.map((f) => {
const result = results.find((r) => r.input_file === f.path);
if (result) {
return {
...f,
status: result.success ? "success" : "error",
error: result.error,
outputPath: result.output_file,
};
}
return f;
}));
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
if (successCount > 0) {
toast.success("Conversion Complete", {
description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
});
}
else if (failCount > 0) {
toast.error("Conversion Failed", {
description: `All ${failCount} file(s) failed to convert`,
});
}
}
catch (err) {
toast.error("Conversion Error", {
description: err instanceof Error ? err.message : "Unknown error",
});
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" })));
}
finally {
setConverting(false);
}
};
const getStatusIcon = (status: AudioFile["status"]) => {
switch (status) {
case "converting":
return <Spinner className="h-4 w-4 text-primary"/>;
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
case "error":
return <AlertCircle className="h-4 w-4 text-destructive"/>;
default:
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
}
};
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
const successCount = files.filter((f) => f.status === "success").length;
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Audio Converter</h1>
{files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/>
Add More
</Button>
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
<Trash2 className="h-4 w-4"/>
Clear All
</Button>
</div>)}
</div>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}} onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}} onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
{files.length === 0 ? (<>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"}
</p>
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5"/>
Select Files
</Button>
<p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3
</p>
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
<div className="space-y-2 pb-4 border-b shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Format:</Label>
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
if (value && !isFormatDisabled)
setOutputFormat(value as "mp3" | "m4a");
}} disabled={isFormatDisabled}>
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
MP3
</ToggleGroupItem>)}
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
M4A
</ToggleGroupItem>
</ToggleGroup>
</div>
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Codec:</Label>
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
if (value)
setM4aCodec(value as "aac" | "alac");
}}>
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label}
</ToggleGroupItem>))}
</ToggleGroup>
</div>)}
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Bitrate:</Label>
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
if (value)
setBitrate(value);
}}>
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label}
</ToggleGroupItem>))}
</ToggleGroup>
</div>)}
</div>
</div>
<div className="flex items-center justify-between shrink-0">
<div className="text-sm text-muted-foreground">
{files.length} file(s) {successCount} converted
</div>
</div>
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
{files.map((file) => (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
{getStatusIcon(file.status)}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.error && (<p className="truncate text-xs text-destructive">
{file.error}
</p>)}
</div>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
<span className="text-xs uppercase text-muted-foreground">
{file.format}
</span>
{file.status !== "converting" && (<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeFile(file.path)} disabled={converting}>
<X className="h-4 w-4"/>
</Button>)}
</div>))}
</div>
<div className="flex justify-center pt-4 border-t shrink-0">
<Button onClick={handleConvert} disabled={converting || convertableCount === 0} size="lg">
{converting ? (<>
<Spinner className="h-4 w-4"/>
Converting...
</>) : (<>
<WandSparkles className="h-4 w-4"/>
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
</>)}
</Button>
</div>
</div>)}
</div>
</div>);
}
-132
View File
@@ -1,132 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { Bug, Trash2, X, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { logger, type LogEntry } from "@/lib/logger";
const levelColors: Record<string, string> = {
info: "text-blue-500",
success: "text-green-500",
warning: "text-yellow-500",
error: "text-red-500",
debug: "text-gray-500",
};
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
export function DebugLogger() {
const [open, setOpen] = useState(false);
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
.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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-50 hover:opacity-100"
>
<Bug className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] p-6 [&>button]:hidden">
<DialogTitle className="text-sm font-medium">Debug Logs</DialogTitle>
<div className="absolute right-4 top-4 flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleCopy}
disabled={logs.length === 0}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleClear}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={() => setOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div
ref={scrollRef}
className="h-[400px] overflow-y-auto bg-muted/50 rounded-md p-3 font-mono text-xs"
>
{logs.length === 0 ? (
<p className="text-muted-foreground lowercase">no logs yet...</p>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}]
</span>
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
[{log.level}]
</span>
<span className="break-all">{log.message}</span>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}
+53 -84
View File
@@ -2,100 +2,72 @@ import { useState, useEffect, useRef } from "react";
import { Trash2, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { logger, type LogEntry } from "@/lib/logger";
const levelColors: Record<string, string> = {
info: "text-blue-500",
success: "text-green-500",
warning: "text-yellow-500",
error: "text-red-500",
debug: "text-gray-500",
info: "text-blue-500",
success: "text-green-500",
warning: "text-yellow-500",
error: "text-red-500",
debug: "text-gray-500",
};
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "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());
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
setLogs(logger.getLogs());
return () => {
unsubscribe();
}
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 () => {
unsubscribe();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
};
const handleCopy = async () => {
const logText = logs
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
.join("\n");
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 500);
} catch (err) {
console.error("Failed to copy logs:", err);
}
};
return (
<div className="space-y-6">
const handleCopy = async () => {
const logText = logs
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
.join("\n");
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 500);
}
catch (err) {
console.error("Failed to copy logs:", err);
}
};
return (<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Debug Logs</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleCopy}
disabled={logs.length === 0}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleClear}
disabled={logs.length === 0}
>
<Trash2 className="h-4 w-4" />
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleClear} disabled={logs.length === 0}>
<Trash2 className="h-4 w-4"/>
Clear
</Button>
</div>
</div>
<div
ref={scrollRef}
className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs"
>
{logs.length === 0 ? (
<p className="text-muted-foreground lowercase">no logs yet...</p>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5">
<div ref={scrollRef} className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs">
{logs.length === 0 ? (<p className="text-muted-foreground lowercase">no logs yet...</p>) : (logs.map((log, i) => (<div key={i} className="flex gap-2 py-0.5">
<span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}]
</span>
@@ -103,10 +75,7 @@ export function DebugLoggerPage() {
[{log.level}]
</span>
<span className="break-all">{log.message}</span>
</div>
))
)}
</div>)))}
</div>
</div>
);
</div>);
}
+14 -14
View File
@@ -1,29 +1,29 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react";
interface DownloadProgressProps {
progress: number;
currentTrack: { name: string; artists: string } | null;
onStop: () => void;
progress: number;
currentTrack: {
name: string;
artists: string;
} | null;
onStop: () => void;
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
return (
<div className="w-full space-y-2 mt-4">
const clampedProgress = Math.min(100, Math.max(0, progress));
return (<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={progress} className="h-2 flex-1" />
<Progress value={clampedProgress} className="h-2 flex-1"/>
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4" />
<StopCircle className="h-4 w-4"/>
Stop
</Button>
</div>
<p className="text-xs text-muted-foreground">
{progress}% -{" "}
{clampedProgress}% -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>
</div>
);
</div>);
}
@@ -2,47 +2,30 @@ import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
import { Download, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DownloadProgressToastProps {
onClick: () => void;
onClick: () => void;
}
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
const progress = useDownloadProgress();
const queueInfo = useDownloadQueueData();
// Show indicator if there are any queued or downloading items
// Don't show for completed/failed/skipped only
const hasActiveDownloads = queueInfo.queue.some(
item => item.status === "queued" || item.status === "downloading"
);
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}
>
const progress = useDownloadProgress();
const queueInfo = useDownloadQueueData();
const hasActiveDownloads = queueInfo.queue.some(item => item.status === "queued" || item.status === "downloading");
if (!hasActiveDownloads) {
return null;
}
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<Button variant="outline" className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer" onClick={onClick}>
<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]">
<p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB
</p>
{progress.speed_mbps > 0 && (
<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps.toFixed(2)} MB/s
</p>
)}
</p>)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
</div>
</Button>
</div>
);
</div>);
}
+230 -274
View File
@@ -1,287 +1,243 @@
import { useEffect, useState } from "react";
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps {
isOpen: boolean;
onClose: () => void;
isOpen: boolean;
onClose: () => void;
}
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
total_downloaded: 0,
session_start_time: 0,
queued_count: 0,
completed_count: 0,
failed_count: 0,
skipped_count: 0,
})
);
useEffect(() => {
if (!isOpen) return;
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
console.error("Failed to get download queue:", error);
}
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
total_downloaded: 0,
session_start_time: 0,
queued_count: 0,
completed_count: 0,
failed_count: 0,
skipped_count: 0,
}));
useEffect(() => {
if (!isOpen)
return;
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
}
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);
}
};
// Initial fetch
fetchQueue();
// Poll every 500ms when dialog is open
const interval = setInterval(fetchQueue, 500);
return () => clearInterval(interval);
}, [isOpen]);
const handleClearHistory = async () => {
try {
await ClearCompletedDownloads();
// Refetch immediately to update UI
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
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 handleReset = async () => {
try {
await ClearAllDownloads();
const info = await GetDownloadQueue();
setQueueInfo(info);
toast.success("Download queue reset");
}
catch (error) {
console.error("Failed to reset queue:", error);
}
};
return (
<Badge variant={variants[status] || "outline"} className="text-xs">
{status}
</Badge>
);
};
// Format session duration
const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0) return "—";
const now = Math.floor(Date.now() / 1000);
const durationSeconds = now - startTimestamp;
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4 pr-8">
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
<div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={handleClearHistory}
>
<Trash2 className="h-3 w-3" />
Clear History
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-muted"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Queue Status */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500" />
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500" />
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div>
{/* Session Stats */}
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div>
</DialogHeader>
{/* Download Queue List */}
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>No downloads in queue</p>
</div>
) : (
queueInfo.queue.map((item) => (
<div
key={item.id}
className="border rounded-lg p-3 hover:bg-muted/30 transition-colors"
>
<div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.track_name}</p>
<p className="text-sm text-muted-foreground truncate">
{item.artist_name}
{item.album_name && `${item.album_name}`}
</p>
</div>
{getStatusBadge(item.status)}
</div>
{/* Info for downloading items */}
{item.status === "downloading" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span>
{item.progress > 0
? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..."
: "Starting..."}
</span>
<span>
{item.speed > 0
? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
)}
{/* Completed info */}
{item.status === "completed" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div>
)}
{/* Skipped info */}
{item.status === "skipped" && (
<div className="mt-1.5 text-xs text-muted-foreground">
File already exists
</div>
)}
{/* Error message */}
{item.status === "failed" && item.error_message && (
<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
{item.error_message}
</div>
)}
{/* File path for completed/skipped */}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (
<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>
)}
</div>
</div>
</div>
))
)}
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",
};
return (<Badge variant={variants[status] || "outline"} className="text-xs">
{status}
</Badge>);
};
const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0)
return "—";
const now = Math.floor(Date.now() / 1000);
const durationSeconds = now - startTimestamp;
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
else {
return `${seconds}s`;
}
};
return (<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4">
<DialogTitle className="text-lg font-semibold hover:text-primary transition-colors cursor-pointer" onClick={handleReset}>Download Queue</DialogTitle>
<div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
<Trash2 className="h-3 w-3"/>
Clear History
</Button>)}
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
<X className="h-4 w-4"/>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500"/>
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div>
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p>No downloads in queue</p>
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
<div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.track_name}</p>
<p className="text-sm text-muted-foreground truncate">
{item.artist_name}
{item.album_name && `${item.album_name}`}
</p>
</div>
{getStatusBadge(item.status)}
</div>
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span>
{item.progress > 0
? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..."
: "Starting..."}
</span>
<span>
{item.speed > 0
? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>)}
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div>)}
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
File already exists
</div>)}
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
{item.error_message}
</div>)}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>)}
</div>
</div>
</div>)))}
</div>
</div>
</DialogContent>
</Dialog>);
}
@@ -0,0 +1,182 @@
import { useState, useEffect } from "react";
import type { DragEvent } from "react";
import { UploadImageBytes, UploadImage, SelectImageVideo } from "../../wailsjs/go/main/App";
import { Upload, Loader2, ImagePlus, X, Check, FileVideo, ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface UploadedFile {
id: string;
name: string;
url: string;
type: 'image' | 'video' | 'unknown';
status: 'uploading' | 'done' | 'error';
error?: string;
}
interface DragDropMediaProps {
value: string;
onChange: (value: string) => void;
className?: string;
}
export function DragDropMedia({ value, onChange, className }: DragDropMediaProps) {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<UploadedFile[]>(() => {
if (!value)
return [];
return value.split('\n').filter(line => line.trim()).map((line, i) => {
const match = line.match(/!\[(.*?)\]\((.*?)\)/);
if (match) {
return {
id: `init-${i}-${Date.now()}`,
name: match[1] === 'image' || match[1] === 'video' ? `file-${i}` : match[1],
url: match[2] || line,
type: (match[2] && match[2].match(/\.(mp4|mkv|webm|mov)$/i)) ? 'video' : 'image',
status: 'done'
};
}
return {
id: `init-${i}-${Date.now()}`,
name: 'unknown',
url: line,
type: 'image',
status: 'done'
};
});
});
useEffect(() => {
const newValue = files
.filter(f => f.status === 'done' && f.url)
.map(f => f.url)
.join('\n');
if (newValue !== value) {
onChange(newValue);
}
}, [files]);
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await handleFiles(Array.from(e.dataTransfer.files));
}
};
const handleFiles = async (fileList: File[]) => {
const timestamp = Date.now();
const newFiles: UploadedFile[] = fileList.map((f, i) => ({
id: `drop-${timestamp}-${i}`,
name: f.name,
url: '',
type: f.type.startsWith('video') ? 'video' : 'image',
status: 'uploading'
}));
setFiles(prev => [...prev, ...newFiles]);
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
const fileId = newFiles[i].id;
try {
const base64 = await fileToBase64(file);
const result = await UploadImageBytes(file.name, base64);
setFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'done', url: result }
: f));
}
catch (err: any) {
console.error("Upload failed", err);
setFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'error', error: err.message || "Upload failed" }
: f));
}
}
};
const handleSelectFile = async () => {
try {
const paths = await SelectImageVideo();
if (paths && paths.length > 0) {
const timestamp = Date.now();
const newFiles: UploadedFile[] = paths.map((p, i) => ({
id: `select-${timestamp}-${i}`,
name: p.split(/[\\/]/).pop() || 'unknown',
url: '',
type: p.match(/\.(mp4|mkv|webm|mov)$/i) ? 'video' : 'image',
status: 'uploading'
}));
setFiles(prev => [...prev, ...newFiles]);
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
const fileId = newFiles[i].id;
try {
const result = await UploadImage(path);
setFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'done', url: result }
: f));
}
catch (err: any) {
setFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'error', error: err.message }
: f));
}
}
}
}
catch (err: any) {
console.error("Select file failed", err);
}
};
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
return (<div className={cn("relative group flex flex-col gap-2 p-4 border-2 border-dashed rounded-lg transition-colors border-muted-foreground/25 hover:border-primary/50 min-h-[14rem]", isDragging ? "border-primary bg-primary/10" : "bg-muted/5", className)} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => {
if (e.target === e.currentTarget)
handleSelectFile();
}}>
{files.length === 0 && (<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none opacity-50">
<ImagePlus className="h-10 w-10 mb-2"/>
<span className="text-sm font-medium">Drop media here or click to browse</span>
<span className="text-xs text-muted-foreground mt-1">Supports PNG, JPG, MP4, MOV</span>
</div>)}
<div className="flex flex-col gap-2 z-10 w-full">
{files.map((file, i) => (<div key={i} className="flex items-center gap-3 p-2 rounded-md bg-background/80 backdrop-blur-sm border shadow-sm animate-in fade-in slide-in-from-bottom-2">
{file.type === 'video' ? <FileVideo className="h-8 w-8 text-primary"/> : <ImageIcon className="h-8 w-8 text-primary"/>}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
{file.status === 'uploading' && <span className="text-yellow-500 flex items-center"><Loader2 className="h-3 w-3 animate-spin mr-1"/> Uploading...</span>}
{file.status === 'done' && <span className="text-green-500 flex items-center"><Check className="h-3 w-3 mr-1"/> Ready</span>}
{file.status === 'error' && <span className="text-red-500">{file.error || 'Failed'}</span>}
</div>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
<X className="h-4 w-4"/>
</Button>
</div>))}
</div>
{isDragging && (<div className="absolute inset-0 flex items-center justify-center bg-background/50 rounded-lg pointer-events-none z-20">
<div className="flex flex-col items-center text-primary font-medium">
<Upload className="h-10 w-10 mb-2 animate-bounce"/>
<span>Drop files to add</span>
</div>
</div>)}
</div>);
}
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
+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 {
id: string;
url: string;
type: "track" | "album" | "playlist" | "artist";
name: string;
artist: string;
image: string;
timestamp: number;
id: string;
url: string;
type: "track" | "album" | "playlist" | "artist";
name: string;
artist: string;
image: string;
timestamp: number;
}
interface FetchHistoryProps {
history: HistoryItem[];
onSelect: (item: HistoryItem) => void;
onRemove: (id: string) => void;
history: HistoryItem[];
onSelect: (item: HistoryItem) => void;
onRemove: (id: string) => void;
}
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
if (history.length === 0) return null;
const getTypeLabel = (type: string) => {
switch (type) {
case "track":
return "Track";
case "album":
return "Album";
case "playlist":
return "Playlist";
case "artist":
return "Artist";
default:
return type;
}
};
return (
<div className="space-y-2">
<span className="text-sm text-muted-foreground">Recent Fetches</span>
if (history.length === 0)
return null;
const getTypeLabel = (type: string) => {
switch (type) {
case "track":
return "Track";
case "album":
return "Album";
case "playlist":
return "Playlist";
case "artist":
return "Artist";
default:
return type;
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case "track":
return Music2;
case "album":
return Disc3;
case "playlist":
return ListMusic;
case "artist":
return UserRound;
default:
return null;
}
};
const getTypeBadgeClass = (type: string) => {
switch (type) {
case "track":
return "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400";
case "album":
return "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400";
case "playlist":
return "bg-purple-500/10 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400";
case "artist":
return "bg-orange-500/10 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400";
default:
return "bg-muted text-muted-foreground";
}
};
return (<div className="space-y-2">
<span className="text-sm text-muted-foreground">{history.length === 1 ? "Recent Fetch" : "Recent Fetches"}</span>
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
{history.map((item) => (
<div
key={item.id}
className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible"
onClick={() => onSelect(item)}
>
<button
type="button"
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
onClick={(e) => {
{history.map((item) => (<div key={item.id} className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible" onClick={() => onSelect(item)}>
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
e.stopPropagation();
onRemove(item.id);
}}
>
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
<div className="p-2">
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
{item.image ? (
<img
src={item.image}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
{item.image ? (<img src={item.image} alt={item.name} className="w-full h-full object-cover"/>) : (<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
No Image
</div>
)}
</div>)}
</div>
<div className="space-y-0.5">
<p className="text-xs font-medium truncate" title={item.name}>
{item.name}
</p>
<p
className="text-xs text-muted-foreground truncate"
title={item.artist}
>
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
{item.artist}
</p>
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{getTypeLabel(item.type)}
</span>
{(() => {
const IconComponent = getTypeIcon(item.type);
return (<span className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded ${getTypeBadgeClass(item.type)}`}>
{IconComponent ? <IconComponent className="h-2.5 w-2.5"/> : null}
{getTypeLabel(item.type)}
</span>);
})()}
</div>
</div>
</div>
))}
</div>))}
</div>
</div>
);
</div>);
}
+739
View File
@@ -0,0 +1,739 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { FolderOpen, RefreshCw, FileMusic, ChevronRight, ChevronDown, Pencil, Eye, Folder, Info, RotateCcw, FileText, Image, Copy, Check, } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { getSettings } from "@/lib/settings";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> => (window as any)['go']['main']['App']['ListDirectoryFiles'](path);
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path);
const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path);
const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
interface FileNode {
name: string;
path: string;
is_dir: boolean;
size: number;
children?: FileNode[];
expanded?: boolean;
}
interface FileMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}
type TabType = "track" | "lyric" | "cover";
const FORMAT_PRESETS: Record<string, {
label: string;
template: string;
}> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
const STORAGE_KEY = "spotiflac_file_manager_state";
const DEFAULT_PRESET = "title-artist";
const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
function formatFileSize(bytes: number): string {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
export function FileManagerPage() {
const [rootPath, setRootPath] = useState(() => {
const settings = getSettings();
return settings.downloadPath || "";
});
const [allFiles, setAllFiles] = useState<FileNode[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>("track");
const [formatPreset, setFormatPreset] = useState<string>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) {
return parsed.formatPreset;
}
}
}
catch { }
return DEFAULT_PRESET;
});
const [customFormat, setCustomFormat] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.customFormat)
return parsed.customFormat;
}
}
catch { }
return DEFAULT_CUSTOM_FORMAT;
});
const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template;
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<backend.RenamePreview[]>([]);
const [renaming, setRenaming] = useState(false);
const [previewOnly, setPreviewOnly] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showMetadata, setShowMetadata] = useState(false);
const [metadataFile, setMetadataFile] = useState<string>("");
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
const [loadingMetadata, setLoadingMetadata] = useState(false);
const [showLyricsPreview, setShowLyricsPreview] = useState(false);
const [lyricsContent, setLyricsContent] = useState("");
const [lyricsFile, setLyricsFile] = useState("");
const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced");
const [copySuccess, setCopySuccess] = useState(false);
const [showCoverPreview, setShowCoverPreview] = useState(false);
const [coverFile, setCoverFile] = useState("");
const [coverData, setCoverData] = useState("");
const [showManualRename, setShowManualRename] = useState(false);
const [manualRenameFile, setManualRenameFile] = useState("");
const [manualRenameName, setManualRenameName] = useState("");
const [manualRenaming, setManualRenaming] = useState(false);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
}
catch { }
}, [formatPreset, customFormat]);
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => {
return nodes
.map((node) => {
if (node.is_dir && node.children) {
const filteredChildren = filterFilesByType(node.children, type);
if (filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
}
const ext = node.name.toLowerCase();
if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")))
return node;
if (type === "lyric" && ext.endsWith(".lrc"))
return node;
if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png")))
return node;
return null;
})
.filter((node): node is FileNode => node !== null);
};
const loadFiles = useCallback(async () => {
if (!rootPath)
return;
setLoading(true);
try {
const result = await ListDirectoryFiles(rootPath);
if (!result || !Array.isArray(result)) {
setAllFiles([]);
setSelectedFiles(new Set());
return;
}
setAllFiles(result as FileNode[]);
setSelectedFiles(new Set());
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err || "");
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
toast.error("Failed to load files", { description: errorMsg || "Unknown error" });
}
setAllFiles([]);
setSelectedFiles(new Set());
}
finally {
setLoading(false);
}
}, [rootPath]);
useEffect(() => {
if (rootPath)
loadFiles();
}, [rootPath, loadFiles]);
const filteredFiles = filterFilesByType(allFiles, activeTab);
const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => {
const result: FileNode[] = [];
for (const node of nodes) {
if (!node.is_dir)
result.push(node);
if (node.children)
result.push(...getAllFilesFlat(node.children));
}
return result;
};
const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track"));
const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric"));
const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover"));
const handleSelectFolder = async () => {
try {
const path = await SelectFolder(rootPath);
if (path)
setRootPath(path);
}
catch (err) {
toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const toggleExpand = (path: string) => {
setAllFiles((prev) => toggleNodeExpand(prev, path));
};
const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => {
return nodes.map((node) => {
if (node.path === path)
return { ...node, expanded: !node.expanded };
if (node.children)
return { ...node, children: toggleNodeExpand(node.children, path) };
return node;
});
};
const toggleSelect = (path: string) => {
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(path))
newSet.delete(path);
else
newSet.add(path);
return newSet;
});
};
const toggleFolderSelect = (node: FileNode) => {
const folderFiles = getAllFilesFlat([node]);
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (allSelected)
folderFiles.forEach((f) => newSet.delete(f.path));
else
folderFiles.forEach((f) => newSet.add(f.path));
return newSet;
});
};
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
const folderFiles = getAllFilesFlat([node]);
if (folderFiles.length === 0)
return false;
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
if (selectedCount === 0)
return false;
if (selectedCount === folderFiles.length)
return true;
return "indeterminate";
};
const selectAll = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
const deselectAll = () => setSelectedFiles(new Set());
const resetToDefault = () => { setFormatPreset(DEFAULT_PRESET); setCustomFormat(DEFAULT_CUSTOM_FORMAT); setShowResetConfirm(false); };
const handlePreview = async (isPreviewOnly: boolean) => {
if (selectedFiles.size === 0) {
toast.error("No files selected");
return;
}
try {
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
setPreviewData(result);
setPreviewOnly(isPreviewOnly);
setShowPreview(true);
}
catch (err) {
toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setMetadataFile(filePath);
setLoadingMetadata(true);
try {
const metadata = await ReadFileMetadata(filePath);
setMetadataInfo(metadata as FileMetadata);
setShowMetadata(true);
}
catch (err) {
toast.error("Failed to read metadata", { description: err instanceof Error ? err.message : "Unknown error" });
setMetadataInfo(null);
}
finally {
setLoadingMetadata(false);
}
};
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setLyricsFile(filePath);
setLyricsTab("synced");
try {
const content = await ReadTextFile(filePath);
setLyricsContent(content);
setShowLyricsPreview(true);
}
catch (err) {
toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowCover = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(filePath);
try {
const data = await ReadImageAsBase64(filePath);
setCoverData(data);
setShowCoverPreview(true);
}
catch (err) {
toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const getPlainLyrics = (content: string) => {
return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim();
};
const formatTimestamp = (timestamp: string): string => {
const match = timestamp.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/);
if (!match)
return timestamp;
const minutes = parseInt(match[1], 10);
const seconds = match[2];
return `${minutes}:${seconds}`;
};
const renderSyncedLyrics = (content: string) => {
if (!content)
return <div className="text-sm text-muted-foreground">No lyrics content</div>;
const lines = content.split('\n');
return lines.map((line, index) => {
if (line.match(/^\[(ti|ar|al|by|length|offset):/i))
return null;
const match = line.match(/^(\[[\d:.]+\])(.*)$/);
if (match) {
const timestamp = match[1];
const text = match[2].trim();
if (!text)
return null;
return (<div key={index} className="flex items-center gap-2 py-1">
<Badge variant="secondary" className="font-mono text-xs shrink-0">
{formatTimestamp(timestamp)}
</Badge>
<span className="text-sm">{text}</span>
</div>);
}
if (!line.trim())
return null;
return (<div key={index} className="py-1">
<span className="text-sm">{line}</span>
</div>);
}).filter(item => item !== null);
};
const handleCopyLyrics = async () => {
try {
const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent);
await navigator.clipboard.writeText(textToCopy);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 500);
}
catch {
toast.error("Failed to copy lyrics");
}
};
const handleManualRename = (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileName = filePath.split(/[/\\]/).pop() || "";
const nameWithoutExt = fileName.replace(/\.[^.]+$/, "");
setManualRenameFile(filePath);
setManualRenameName(nameWithoutExt);
setShowManualRename(true);
};
const handleConfirmManualRename = async () => {
if (!manualRenameFile || !manualRenameName.trim())
return;
setManualRenaming(true);
try {
await RenameFileTo(manualRenameFile, manualRenameName.trim());
toast.success("File renamed successfully");
setShowManualRename(false);
loadFiles();
}
catch (err) {
toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" });
}
finally {
setManualRenaming(false);
}
};
const handleRename = async () => {
if (selectedFiles.size === 0)
return;
setRenaming(true);
try {
const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat);
const successCount = result.filter((r: backend.RenameResult) => r.success).length;
const failCount = result.filter((r: backend.RenameResult) => !r.success).length;
if (successCount > 0)
toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` });
else
toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` });
setShowPreview(false);
setSelectedFiles(new Set());
loadFiles();
}
catch (err) {
toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" });
}
finally {
setRenaming(false);
}
};
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`} style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}>
{node.is_dir ? (<>
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
if (el)
(el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked";
}} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<>
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/>
<FileMusic className="h-4 w-4 text-primary shrink-0"/>
</>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleShowMetadata(node.path, e)}>
<Info className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>View Metadata</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
</div>));
};
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}>
{node.is_dir ? (<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
</div>));
};
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}>
{node.is_dir ? (<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
</div>));
};
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">File Manager</h1>
</div>
<div className="flex items-center gap-2 shrink-0">
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
<Button onClick={handleSelectFolder}>
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}/>
Refresh
</Button>
</div>
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
<FileMusic className="h-4 w-4"/>
Track ({allAudioFiles.length})
</Button>
<Button variant={activeTab === "lyric" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("lyric")} className="rounded-b-none">
<FileText className="h-4 w-4"/>
Lyric ({allLyricFiles.length})
</Button>
<Button variant={activeTab === "cover" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("cover")} className="rounded-b-none">
<Image className="h-4 w-4"/>
Cover ({allCoverFiles.length})
</Button>
</div>
{activeTab === "track" && (<div className="space-y-2 shrink-0">
<div className="flex items-center gap-2">
<Label className="text-sm">Rename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Select value={formatPreset} onValueChange={setFormatPreset}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent>
</Select>
{formatPreset === "custom" && (<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
<RotateCcw className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>Reset to Default</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>
</div>)}
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
{activeTab === "track" && (<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
{allSelected ? "Deselect All" : "Select All"}
</Button>
<span className="text-sm text-muted-foreground">{selectedFiles.size} of {allAudioFiles.length} file(s) selected</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
<Eye className="h-4 w-4"/>
Preview
</Button>
<Button size="sm" onClick={() => handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
<Pencil className="h-4 w-4"/>
Rename
</Button>
</div>
</div>)}
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
{loading ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : filteredFiles.length === 0 ? (<div className="text-center py-8 text-muted-foreground">
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
</div>) : (activeTab === "track" ? renderTrackTree(filteredFiles) :
activeTab === "lyric" ? renderLyricTree(filteredFiles) :
renderCoverTree(filteredFiles))}
</div>
</div>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={resetToDefault}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename Preview</DialogTitle>
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 py-4">
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
<div className="text-sm">
<div className="text-muted-foreground break-all">{item.old_name}</div>
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1"> {item.new_name}</div>}
</div>
</div>))}
</div>
<DialogFooter>
{previewOnly ? (<Button onClick={() => setShowPreview(false)}>Close</Button>) : (<>
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
<Button onClick={handleRename} disabled={renaming}>
{renaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
</Button>
</>)}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>File Metadata</DialogTitle>
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
{loadingMetadata ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : metadataInfo ? (<div className="space-y-3 py-2">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album Artist</span><span>{metadataInfo.album_artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Lyrics Preview</DialogTitle>
<DialogDescription className="break-all">{lyricsFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 border-b pb-2">
<Button variant={lyricsTab === "synced" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("synced")}>Synced</Button>
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
</div>
<div className="flex-1 overflow-y-auto py-4">
{lyricsTab === "synced" ? (<div className="bg-muted/30 p-4 rounded-lg space-y-0">
{renderSyncedLyrics(lyricsContent)}
</div>) : (<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
{getPlainLyrics(lyricsContent) || "No lyrics content"}
</pre>)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
{copySuccess ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy
</Button>
<Button onClick={() => setShowLyricsPreview(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
<DialogContent className="max-w-lg [&>button]:hidden">
<DialogHeader>
<DialogTitle>Cover Preview</DialogTitle>
<DialogDescription className="break-all">{coverFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center p-4">
{coverData ? <img src={coverData} alt="Cover" className="max-w-full max-h-[350px] rounded-lg object-contain"/> : <div className="text-muted-foreground">Loading...</div>}
</div>
<DialogFooter><Button onClick={() => setShowCoverPreview(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
<DialogContent className="max-w-2xl [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
<DialogDescription className="break-all">{manualRenameFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="newName" className="text-sm">New Name</Label>
<div className="flex items-center gap-2 mt-2">
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => {
if (e.key === "Enter" && !manualRenaming)
handleConfirmManualRename();
}}/>
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+14 -38
View File
@@ -1,66 +1,42 @@
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/relative-time";
interface HeaderProps {
version: string;
hasUpdate: boolean;
releaseDate?: string | null;
version: string;
hasUpdate: boolean;
releaseDate?: string | null;
}
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return (
<div className="relative">
return (<div className="relative">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-3">
<img
src="/icon.svg"
alt="SpotiFLAC"
className="w-12 h-12 cursor-pointer"
onClick={() => window.location.reload()}
/>
<h1
className="text-4xl font-bold cursor-pointer"
onClick={() => window.location.reload()}
>
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
SpotiFLAC
</h1>
<div className="relative">
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" asChild>
<button
type="button"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
<button type="button" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")} className="cursor-pointer hover:opacity-80 transition-opacity">
v{version}
</button>
</Badge>
</TooltipTrigger>
{hasUpdate && releaseDate && (
<TooltipContent>
{hasUpdate && releaseDate && (<TooltipContent>
<p>{formatRelativeTime(releaseDate)}</p>
</TooltipContent>
)}
</TooltipContent>)}
</Tooltip>
{hasUpdate && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
{hasUpdate && (<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
)}
</span>)}
</div>
</div>
<p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music no account required.
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required.
</p>
</div>
</div>
);
</div>);
}
+611
View File
@@ -0,0 +1,611 @@
import { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause, Database, CloudUpload, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
interface DownloadHistoryItem {
id: string;
spotify_id: string;
title: string;
artists: string;
album: string;
duration_str: string;
cover_url: string;
quality: string;
format: string;
path: string;
timestamp: number;
}
interface FetchHistoryItem {
id: string;
url: string;
type: string;
name: string;
info: string;
image: string;
data: string;
timestamp: number;
}
interface HistoryPageProps {
onHistorySelect?: (cachedData: string) => void;
}
export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
const [activeTab, setActiveTab] = useState("downloads");
const [downloadHistory, setDownloadHistory] = useState<DownloadHistoryItem[]>([]);
const [filteredDownloadHistory, setFilteredDownloadHistory] = useState<DownloadHistoryItem[]>([]);
const [showClearDownloadConfirm, setShowClearDownloadConfirm] = useState(false);
const [downloadSearchQuery, setDownloadSearchQuery] = useState("");
const [downloadSortBy, setDownloadSortBy] = useState("default");
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
const [activeFetchTab, setActiveFetchTab] = useState("track");
const [showClearFetchConfirm, setShowClearFetchConfirm] = useState(false);
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 50;
const fetchDownloadHistory = async () => {
try {
const items = await GetDownloadHistory();
setDownloadHistory(items || []);
}
catch (err) {
console.error("Failed to fetch download history:", err);
}
};
const fetchFetchHistory = async () => {
try {
const items = await GetFetchHistory();
setFetchHistory(items || []);
}
catch (err) {
console.error("Failed to fetch fetch history:", err);
}
};
useEffect(() => {
if (activeTab === "downloads") {
fetchDownloadHistory();
const interval = setInterval(fetchDownloadHistory, 5000);
return () => clearInterval(interval);
}
else {
fetchFetchHistory();
const interval = setInterval(fetchFetchHistory, 5000);
return () => clearInterval(interval);
}
}, [activeTab]);
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
}
};
}, []);
useEffect(() => {
let result = [...downloadHistory];
if (downloadSearchQuery) {
const query = downloadSearchQuery.toLowerCase();
result = result.filter(item => item.title.toLowerCase().includes(query) ||
item.artists.toLowerCase().includes(query) ||
item.album.toLowerCase().includes(query));
}
const parseDuration = (str: string) => {
const parts = str.split(':').map(Number);
if (parts.length === 2)
return parts[0] * 60 + parts[1];
if (parts.length === 3)
return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
};
result.sort((a, b) => {
switch (downloadSortBy) {
case "default":
case "date_desc": return b.timestamp - a.timestamp;
case "date_asc": return a.timestamp - b.timestamp;
case "title_asc": return a.title.localeCompare(b.title);
case "title_desc": return b.title.localeCompare(a.title);
case "artist_asc": return a.artists.localeCompare(b.artists);
case "artist_desc": return b.artists.localeCompare(a.artists);
case "duration_asc": return parseDuration(a.duration_str) - parseDuration(b.duration_str);
case "duration_desc": return parseDuration(b.duration_str) - parseDuration(a.duration_str);
default: return 0;
}
});
setFilteredDownloadHistory(result);
setDownloadCurrentPage(1);
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
useEffect(() => {
let result = [...fetchHistory];
if (activeFetchTab !== "all") {
result = result.filter(item => item.type.toLowerCase() === activeFetchTab.toLowerCase());
}
if (fetchSearchQuery) {
const query = fetchSearchQuery.toLowerCase();
result = result.filter(item => item.name.toLowerCase().includes(query) ||
item.info.toLowerCase().includes(query));
}
result.sort((a, b) => b.timestamp - a.timestamp);
setFilteredFetchHistory(result);
setFetchCurrentPage(1);
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) {
audioRef.current?.pause();
setPlayingPreviewId(null);
return;
}
if (audioRef.current) {
audioRef.current.pause();
}
try {
const url = await GetPreviewURL(spotifyId);
if (url) {
const audio = new Audio(url);
audioRef.current = audio;
audio.volume = 0.5;
audio.onended = () => setPlayingPreviewId(null);
audio.play();
setPlayingPreviewId(id);
}
}
catch (e) {
console.error("Failed to play preview:", e);
}
};
const handleClearDownloadHistory = async () => {
await ClearDownloadHistory();
fetchDownloadHistory();
setShowClearDownloadConfirm(false);
};
const handleDeleteDownloadItem = async (id: string) => {
await DeleteDownloadHistoryItem(id);
setDownloadHistory(prev => prev.filter(item => item.id !== id));
};
const handleClearFetchHistory = async () => {
await ClearFetchHistoryByType(activeFetchTab);
fetchFetchHistory();
setShowClearFetchConfirm(false);
};
const handleDeleteFetchItem = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
await DeleteFetchHistoryItem(id);
setFetchHistory(prev => prev.filter(item => item.id !== id));
};
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
if (total <= 10)
return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | 'ellipsis')[] = [];
pages.push(1);
if (current <= 7) {
for (let i = 2; i <= 10; i++)
pages.push(i);
pages.push('ellipsis');
pages.push(total);
}
else if (current >= total - 7) {
pages.push('ellipsis');
for (let i = total - 9; i <= total; i++)
pages.push(i);
}
else {
pages.push('ellipsis');
pages.push(current - 1);
pages.push(current);
pages.push(current + 1);
pages.push('ellipsis');
pages.push(total);
}
return pages;
};
const renderDownloadHistory = () => {
const totalPages = Math.ceil(filteredDownloadHistory.length / ITEMS_PER_PAGE);
const startIndex = (downloadCurrentPage - 1) * ITEMS_PER_PAGE;
const paginated = filteredDownloadHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
return (<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
{downloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
{downloadHistory.length.toLocaleString('en-US')}
</Badge>)}
</div>
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
<Trash2 className="h-4 w-4"/> Clear All
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div>
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
<SelectTrigger className="w-[180px] h-9">
<ArrowUpDown className="mr-2 h-4 w-4"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="date_desc">Date (Newest)</SelectItem>
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-md border overflow-hidden">
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
<History className="h-10 w-10 opacity-40"/>
</div>
<div className="space-y-1">
<p className="font-medium text-foreground/80">No download history</p>
<p className="text-sm">Your downloaded tracks will appear here.</p>
</div>
</div>) : (<table className="w-full table-fixed">
<thead>
<tr className="border-b bg-muted/50">
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
{startIndex + index + 1}
</td>
<td className="p-3 align-middle min-w-0">
<div className="flex items-center gap-3 min-w-0">
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-sm truncate">{item.title}</span>
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
</div>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground">
{['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format}
</span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
{item.duration_str}
</td>
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
<div className="flex flex-col">
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
</div>
</td>
<td className="p-3 align-middle text-center">
<div className="flex items-center justify-center gap-1">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
{playingPreviewId === item.id ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
<ExternalLink className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open in Spotify</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" onClick={() => handleDeleteDownloadItem(item.id)}>
<Trash2 className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</td>
</tr>))}
</tbody>
</table>)}
</div>
{totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (downloadCurrentPage > 1)
setDownloadCurrentPage(downloadCurrentPage - 1);
}} className={downloadCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
{getPaginationPages(downloadCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setDownloadCurrentPage(page as number);
}} isActive={downloadCurrentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>)))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (downloadCurrentPage < totalPages)
setDownloadCurrentPage(downloadCurrentPage + 1);
}} className={downloadCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
</PaginationContent>
</Pagination>)}
</div>);
};
const renderFetchHistory = () => {
const totalPages = Math.ceil(filteredFetchHistory.length / ITEMS_PER_PAGE);
const startIndex = (fetchCurrentPage - 1) * ITEMS_PER_PAGE;
const paginated = filteredFetchHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
return (<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold tracking-tight">Fetches</h2>
{fetchHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
{fetchHistory.length.toLocaleString('en-US')}
</Badge>)}
</div>
<Button variant="outline" size="sm" onClick={() => setShowClearFetchConfirm(true)} disabled={fetchHistory.length === 0} className="cursor-pointer gap-2">
<Trash2 className="h-4 w-4"/> Clear All
</Button>
</div>
<div className="flex flex-col gap-4">
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeFetchTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("track")} className="rounded-b-none">
<Music2 className="h-4 w-4"/>
Tracks
</Button>
<Button variant={activeFetchTab === "album" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("album")} className="rounded-b-none">
<Disc3 className="h-4 w-4"/>
Albums
</Button>
<Button variant={activeFetchTab === "playlist" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("playlist")} className="rounded-b-none">
<ListMusic className="h-4 w-4"/>
Playlists
</Button>
<Button variant={activeFetchTab === "artist" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("artist")} className="rounded-b-none">
<UserRound className="h-4 w-4"/>
Artists
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Search fetch history..." value={fetchSearchQuery} onChange={(e) => setFetchSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div>
</div>
</div>
</div>
<div className="rounded-md border overflow-hidden">
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground gap-3">
<Database className="h-10 w-10 opacity-40"/>
<div className="space-y-1">
<p className="font-medium text-foreground/80">No fetch history</p>
<p className="text-sm">Fetched metadata will appear here.</p>
</div>
</div>) : (<table className="w-full table-fixed">
<thead>
<tr className="border-b bg-muted/50">
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase w-1/3">
{activeFetchTab === 'artist' ? 'Name' : 'Title'}
</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase">Details</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-40 text-xs uppercase text-nowrap">Fetched At</th>
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
{startIndex + index + 1}
</td>
<td className="p-3 align-middle min-w-0">
<div className="flex items-center gap-3 min-w-0">
<div className="h-10 w-10 rounded shrink-0 bg-secondary overflow-hidden">
{item.image ? (<img src={item.image} alt={item.name} className="h-full w-full object-cover"/>) : (<div className="h-full w-full flex items-center justify-center text-xs text-muted-foreground font-medium bg-muted">
{item.type.slice(0, 2).toUpperCase()}
</div>)}
</div>
<span className="font-medium text-sm truncate">{item.name}</span>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.info}</div>
</td>
<td className="p-3 align-middle text-xs text-muted-foreground hidden lg:table-cell whitespace-nowrap">
<div className="flex flex-col">
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
</div>
</td>
<td className="p-3 align-middle text-center">
<div className="flex items-center justify-center gap-1">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => onHistorySelect?.(item.data)}>
<CloudUpload className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Load</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" onClick={(e) => handleDeleteFetchItem(item.id, e)}>
<Trash2 className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</td>
</tr>))}
</tbody>
</table>)}
</div>
{totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (fetchCurrentPage > 1)
setFetchCurrentPage(fetchCurrentPage - 1);
}} className={fetchCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
{getPaginationPages(fetchCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setFetchCurrentPage(page as number);
}} isActive={fetchCurrentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>)))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (fetchCurrentPage < totalPages)
setFetchCurrentPage(fetchCurrentPage + 1);
}} className={fetchCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
</PaginationContent>
</Pagination>)}
</div>);
};
return (<div className="space-y-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">History</h1>
</div>
<div className="border-b">
<div className="flex gap-6">
<button onClick={() => setActiveTab("downloads")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "downloads" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Downloads
</button>
<button onClick={() => setActiveTab("fetches")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "fetches" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
Fetches
</button>
</div>
</div>
{activeTab === "downloads" && (<div className="mt-6">
{renderDownloadHistory()}
</div>)}
{activeTab === "fetches" && (<div className="mt-6">
{renderFetchHistory()}
</div>)}
<Dialog open={showClearDownloadConfirm} onOpenChange={setShowClearDownloadConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Clear Download History?</DialogTitle>
<DialogDescription>
This will remove all entries from your download history. This action cannot be undone.
Note: The actual downloaded files will NOT be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearDownloadConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearDownloadHistory} className="cursor-pointer">
Clear History
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showClearFetchConfirm} onOpenChange={setShowClearFetchConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Clear {activeFetchTab.charAt(0).toUpperCase() + activeFetchTab.slice(1)} History?</DialogTitle>
<DialogDescription>
This will remove all {activeFetchTab} entries from your fetch history cache.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearFetchConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearFetchHistory} className="cursor-pointer">
Clear History
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+12 -22
View File
@@ -1,28 +1,18 @@
// Platform Icons for streaming services
export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
export const TidalIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
export const DeezerIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
</svg>);
export const QobuzIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>
);
export const AmazonIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
</svg>);
export const AmazonIcon = ({ className = "w-4 h-4" }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>
);
</svg>);
+115 -203
View File
@@ -1,244 +1,156 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, ImageDown } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface PlaylistInfoProps {
playlistInfo: {
owner: {
name: string;
display_name: string;
images: string;
playlistInfo: {
owner: {
name: string;
display_name: string;
images: string;
};
tracks: {
total: number;
};
followers: {
total: number;
};
cover?: string;
description?: string;
};
tracks: {
total: number;
};
followers: {
total: number;
};
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
onTrackClick: (track: TrackMetadata) => void;
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onAlbumClick: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick: (track: TrackMetadata) => void;
onBack?: () => 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,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadLyrics,
onDownloadCover,
onCheckAvailability,
onDownloadAllCovers,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
onAlbumClick,
onArtistClick,
onTrackClick,
}: PlaylistInfoProps) {
return (
<div className="space-y-6">
<Card>
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, onBack, }: PlaylistInfoProps) {
return (<div className="space-y-6">
<Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{playlistInfo.owner.images && (
<img
src={playlistInfo.owner.images}
alt={playlistInfo.owner.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Playlist</p>
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
{playlistInfo.description && (<p className="text-sm text-muted-foreground">{playlistInfo.description}</p>)}
<div className="flex items-center gap-2 text-sm">
<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>{playlistInfo.tracks.total} songs</span>
<span>
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
</span>
<span></span>
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
<span>{playlistInfo.followers.total.toLocaleString()} {playlistInfo.followers.total === 1 ? "follower" : "followers"}</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download All
</Button>
{selectedTracks.length > 0 && (
<Button
onClick={onDownloadSelected}
variant="secondary"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "selected" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
Download Selected ({selectedTracks.length})
</Button>
)}
{onDownloadAllCovers && (
<Tooltip>
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
Download Selected ({selectedTracks.length.toLocaleString()})
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onDownloadAllCovers}
variant="outline"
disabled={isBulkDownloadingCovers}
>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>
)}
</Button>)}
</div>
{isDownloading && (
<DownloadProgress
progress={downloadProgress}
currentTrack={currentDownloadInfo}
onStop={onStopDownload}
/>
)}
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<SearchAndSort
searchQuery={searchQuery}
sortBy={sortBy}
onSearchChange={onSearchChange}
onSortChange={onSortChange}
/>
<TrackList
tracks={trackList}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={downloadedTracks}
failedTracks={failedTracks}
skippedTracks={skippedTracks}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={false}
folderName={playlistInfo.owner.name}
downloadedLyrics={downloadedLyrics}
failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange}
onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick}
onTrackClick={onTrackClick}
/>
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={playlistInfo.owner.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>
</div>
);
</div>);
}
+17 -41
View File
@@ -1,50 +1,25 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Search, ArrowUpDown, XCircle } from "lucide-react";
interface SearchAndSortProps {
searchQuery: string;
sortBy: string;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
searchQuery: string;
sortBy: string;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
}
export function SearchAndSort({
searchQuery,
sortBy,
onSearchChange,
onSortChange,
}: SearchAndSortProps) {
return (
<div className="flex gap-2">
export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChange, }: SearchAndSortProps) {
return (<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tracks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 pr-8"
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onSearchChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Search tracks..." value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} className="pl-10 pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onSearchChange("")}>
<XCircle className="h-4 w-4"/>
</button>)}
</div>
<Select value={sortBy} onValueChange={onSortChange}>
<SelectTrigger className="w-[200px] gap-1.5">
<ArrowUpDown className="h-4 w-4" />
<SelectValue placeholder="Sort by" />
<ArrowUpDown className="h-4 w-4"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
@@ -54,10 +29,11 @@ export function SearchAndSort({
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
<SelectItem value="plays-asc">Plays (Low)</SelectItem>
<SelectItem value="plays-desc">Plays (High)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
</SelectContent>
</Select>
</div>
);
</div>);
}
+391 -84
View File
@@ -1,93 +1,400 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import { Search, Info, XCircle } from "lucide-react";
import { CloudDownload, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } from "@/components/FetchHistory";
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { cn } from "@/lib/utils";
import { useTypingEffect } from "@/hooks/useTypingEffect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const FETCH_PLACEHOLDERS = [
"https://open.spotify.com/track/...",
"https://open.spotify.com/album/...",
"https://open.spotify.com/playlist/...",
"https://open.spotify.com/artist/..."
];
const SEARCH_PLACEHOLDERS = [
"Golden",
"Taylor Swift",
"The Weeknd",
"Starboy",
"Joji",
"Die For You"
];
const REGIONS = ["AD", "AE", "AG", "AL", "AM", "AO", "AR", "AT", "AU", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BN", "BO", "BR", "BS", "BT", "BW", "BZ", "CA", "CD", "CG", "CH", "CI", "CL", "CM", "CO", "CR", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ES", "ET", "FI", "FJ", "FM", "FR", "GA", "GB", "GD", "GE", "GH", "GM", "GN", "GQ", "GR", "GT", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", "KW", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH", "MK", "ML", "MN", "MO", "MR", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NE", "NG", "NI", "NL", "NO", "NP", "NR", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PS", "PT", "PW", "PY", "QA", "RO", "RS", "RW", "SA", "SB", "SC", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SR", "ST", "SV", "SZ", "TD", "TG", "TH", "TJ", "TL", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VC", "VE", "VN", "VU", "WS", "XK", "ZA", "ZM", "ZW"];
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
const getRegionName = (code: string) => {
try {
if (code === "XK")
return "Kosovo";
return regionNames.of(code) || code;
}
catch (e) {
return code;
}
};
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
const MAX_RECENT_SEARCHES = 8;
const SEARCH_LIMIT = 50;
interface SearchBarProps {
url: string;
loading: boolean;
onUrlChange: (url: string) => void;
onFetch: () => void;
history: HistoryItem[];
onHistorySelect: (item: HistoryItem) => void;
onHistoryRemove: (id: string) => void;
hasResult: boolean;
url: string;
loading: boolean;
onUrlChange: (url: string) => void;
onFetch: () => void;
onFetchUrl: (url: string) => Promise<void>;
history: HistoryItem[];
onHistorySelect: (item: HistoryItem) => void;
onHistoryRemove: (id: string) => void;
hasResult: boolean;
searchMode: boolean;
onSearchModeChange: (isSearch: boolean) => void;
region: string;
onRegionChange: (region: string) => void;
}
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange }: SearchBarProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
tracks: false,
albums: false,
artists: false,
playlists: false,
});
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
const placeholderText = useTypingEffect(placeholders);
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="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
{searchMode ? <Link className="h-4 w-4"/> : <Search className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
</TooltipContent>
</Tooltip>
export function SearchBar({
url,
loading,
onUrlChange,
onFetch,
history,
onHistorySelect,
onHistoryRemove,
hasResult,
}: SearchBarProps) {
return (
<div className="space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<InputWithContext
id="spotify-url"
placeholder="https://open.spotify.com/..."
value={url}
onChange={(e) => onUrlChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onFetch()}
className="pr-8"
/>
{url && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
<Spinner />
Fetching...
</>
) : (
<>
<Search className="h-4 w-4" />
Fetch
</>
)}
</Button>
</div>
</div>
{!hasResult && (
<FetchHistory
history={history}
onSelect={onHistorySelect}
onRemove={onHistoryRemove}
/>
)}
</div>
);
<div className="relative flex-1">
{!searchMode ? (<>
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
<XCircle className="h-4 w-4"/>
</button>)}
</>) : (<>
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
setSearchQuery("");
setSearchResults(null);
setLastSearchedQuery("");
}}>
<XCircle className="h-4 w-4"/>
</button>)}
</>)}
</div>
{!searchMode && (<>
<Select value={region} onValueChange={onRegionChange}>
<SelectTrigger className="w-[70px] shrink-0">
<SelectValue placeholder="Region"/>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
{r} <span className="text-muted-foreground">({getRegionName(r)})</span>
</SelectItem>))}
</SelectContent>
</Select>
<Button onClick={onFetch} disabled={loading}>
{loading ? (<>
<Spinner />
Fetching...
</>) : (<>
<CloudDownload className="h-4 w-4"/>
Fetch
</>)}
</Button>
</>)}
</div>
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
{searchMode && (<div className="space-y-4">
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2">
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
<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">
<div className="flex items-center gap-1.5 min-w-0">
<p className="font-medium truncate">{track.name}</p>
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
E
</span>)}
</div>
<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>);
}
-459
View File
@@ -1,459 +0,0 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
// 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="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
const DeezerIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>
);
const AmazonIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>
);
export function Settings() {
const [open, setOpen] = useState(false);
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [, setIsLoadingDefaults] = useState(false);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
// Apply saved settings
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
// Setup listener for system theme changes
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]);
// Apply temp settings for preview when dialog is open
useEffect(() => {
if (open) {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
// Update isDark state after theme is applied
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
// Setup listener for system theme changes during preview
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (tempSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(tempSettings.theme);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}
}, [open, tempSettings.themeMode, tempSettings.theme]);
useEffect(() => {
// Load settings with defaults from backend on mount
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
setIsLoadingDefaults(true);
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
setIsLoadingDefaults(false);
}
};
loadDefaults();
}, []);
// Reset temp settings when dialog opens
useEffect(() => {
if (open) {
setTempSettings(savedSettings);
}
}, [open, savedSettings]);
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
setOpen(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
// Apply default theme mode and theme
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
};
const handleCancel = () => {
// Revert to saved settings
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
setTempSettings(savedSettings);
setOpen(false);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Dialog is closing, revert to saved settings
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
setTempSettings(savedSettings);
}
setOpen(newOpen);
};
const handleDownloadPathChange = (value: string) => {
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
};
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => {
setTempSettings((prev) => ({ ...prev, downloader: value }));
};
const handleThemeChange = (value: string) => {
setTempSettings((prev) => ({ ...prev, theme: value }));
};
const handleThemeModeChange = (value: "auto" | "light" | "dark") => {
setTempSettings((prev) => ({ ...prev, themeMode: value }));
};
const handleBrowseFolder = async () => {
try {
// Call backend to open folder selection dialog
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
console.log("Selected path:", selectedPath);
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
} else {
console.log("No folder selected or user cancelled");
}
} catch (error) {
console.error("Error selecting folder:", error);
alert(`Error selecting folder: ${error}`);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<SettingsIcon className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] flex flex-col p-6 [&>button]:hidden" aria-describedby={undefined}>
<div className="absolute right-4 top-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleCancel}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Settings</DialogTitle>
<div className="grid grid-cols-[1.2fr_0.8fr] gap-6 py-2">
{/* Left Column */}
<div className="space-y-4">
{/* Download Path */}
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext
id="download-path"
value={tempSettings.downloadPath}
onChange={(e) => handleDownloadPathChange(e.target.value)}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
</div>
{/* Source Selection */}
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<Select
value={tempSettings.downloader}
onValueChange={handleDownloaderChange}
>
<SelectTrigger id="downloader">
<SelectValue placeholder="Select a source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="deezer">
<span className="flex items-center">
<DeezerIcon />
Deezer
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center">
<AmazonIcon />
Amazon Music
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Mode Selection */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Accent Selection */}
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full border border-border"
style={{
backgroundColor: isDark
? theme.cssVars.dark.primary
: theme.cssVars.light.primary
}}
/>
{theme.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
{/* Folder Structure */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.folderPreset === "custom" && (
<InputWithContext
value={tempSettings.folderTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
placeholder="{artist}/{album}"
className="h-9 text-sm"
/>
)}
{tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
</p>
)}
</div>
<div className="border-t" />
{/* Filename Format */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.filenamePreset}
onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (
<InputWithContext
value={tempSettings.filenameTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
placeholder="{track}. {title}"
className="h-9 text-sm"
/>
)}
{tempSettings.filenameTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
</p>
)}
</div>
<div className="border-t" />
{/* Sound Effects */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Volume2 className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
</div>
<Switch
id="sfx-enabled"
checked={tempSettings.sfxEnabled}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
/>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="outline" onClick={handleReset} className="gap-1.5">
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" />
Save Changes
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+389 -316
View File
@@ -1,162 +1,219 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
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 { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
// Service Icons
const TidalIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
const DeezerIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>
);
const AmazonIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>
);
export function SettingsPage() {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
const TidalIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>);
const QobuzIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>);
const AmazonIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>);
interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void;
}
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const [showResetConfirm, setShowResetConfirm] = useState(false);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
flushSync(() => {
setTempSettings(freshSavedSettings);
setIsDark(document.documentElement.classList.contains('dark'));
});
}, []);
useEffect(() => {
if (onResetRequest) {
onResetRequest(resetToSaved);
}
}, [onResetRequest, resetToSaved]);
useEffect(() => {
onUnsavedChangesChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChangesChange]);
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [savedSettings.themeMode, savedSettings.theme]);
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
await saveSettings(settingsWithDefaults);
}
};
loadDefaults();
}, []);
const handleSave = async () => {
await saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
onUnsavedChangesChange?.(false);
};
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);
}
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false);
toast.success("Settings reset to default");
};
loadDefaults();
}, []);
const handleBrowseFolder = async () => {
try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
}
}
catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
};
const handleQobuzQualityChange = (value: "6" | "7") => {
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
};
const handleAutoQualityChange = async (value: "16" | "24") => {
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
};
return (<div className="space-y-4">
<h1 className="text-2xl font-bold">Settings</h1>
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
};
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
toast.success("Settings reset to default");
};
<div className="space-y-3">
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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4">
{/* Download Path */}
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext
id="download-path"
value={tempSettings.downloadPath}
onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
<div className="space-y-1">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
</div>
</div>
{/* Source Selection */}
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<Select
value={tempSettings.downloader}
onValueChange={(value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
>
<SelectTrigger id="downloader">
<SelectValue placeholder="Select a source" />
<div className="space-y-1">
<Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme"/>
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}}/>
{theme.label}
</span>
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font">
<SelectValue placeholder="Select a font"/>
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="downloader" className="text-sm">Source</Label>
<div className="flex gap-2">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center"><TidalIcon />Tidal</span>
</SelectItem>
<SelectItem value="deezer">
<span className="flex items-center"><DeezerIcon />Deezer</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center"><QobuzIcon />Qobuz</span>
</SelectItem>
@@ -165,201 +222,217 @@ export function SettingsPage() {
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Mode */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label>
<Select
value={tempSettings.themeMode}
onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}
>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Accent */}
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select
value={tempSettings.theme}
onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}
>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full border border-border"
style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}}
/>
{theme.label}
</span>
{tempSettings.downloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({ ...prev, autoOrder: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tidal-qobuz">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<SelectItem value="tidal-amazon">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="qobuz-tidal">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="amazon-tidal">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="amazon-qobuz">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="tidal-amazon-qobuz">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="qobuz-tidal-amazon">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="qobuz-amazon-tidal">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="amazon-tidal-qobuz">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/></span>
</SelectItem>
<SelectItem value="amazon-qobuz-tidal">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-current"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-current"/></span>
</SelectItem>
</SelectContent>
</Select>
{/* Font */}
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select
value={tempSettings.fontFamily}
onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}
>
<SelectTrigger id="font">
<SelectValue placeholder="Select a font" />
<Select value={tempSettings.autoQuality || "16"} onValueChange={handleAutoQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
<SelectItem value="24">24-bit/48kHz</SelectItem>
</SelectContent>
</Select>
</>)}
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
</SelectItem>
))}
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
</SelectContent>
</Select>
</Select>)}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
<SelectItem value="7">24-bit/48kHz</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit/44.1kHz / 24-bit/48kHz
</div>)}
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
{/* Folder Structure */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.folderPreset === "custom" && (
<InputWithContext
value={tempSettings.folderTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
placeholder="{artist}/{album}"
className="h-9 text-sm"
/>
)}
{tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
</p>
)}
</div>
{((tempSettings.downloader === "tidal" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(tempSettings.downloader === "qobuz" && tempSettings.qobuzQuality === "7") ||
(tempSettings.downloader === "auto" && tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pl-1">
<div className="flex items-center space-x-2">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, allowFallback: checked }))}/>
<Label htmlFor="allow-fallback" className="text-sm font-normal">Allow Quality Fallback (16-bit)</Label>
</div>
</div>)}
<div className="border-t pt-4" />
{/* Filename Format */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.filenamePreset}
onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (
<InputWithContext
value={tempSettings.filenameTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
placeholder="{track}. {title}"
className="h-9 text-sm"
/>
)}
{tempSettings.filenameTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
</p>
)}
</div>
<div className="border-t pt-4" />
{/* Sound Effects */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-3">
<Volume2 className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch
id="sfx-enabled"
checked={tempSettings.sfxEnabled}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
/>
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={handleReset} className="gap-1.5">
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" />
Save Changes
</Button>
<div className="border-t"/>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
}));
}}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent>
</Select>
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
</div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
</p>)}
</div>
<div className="border-t"/>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
}));
}}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>)}
</div>
</div>
</div>
);
<div className="flex gap-2 justify-between pt-3 border-t">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4"/>
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4"/>
Save Changes
</Button>
</div>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>
This will reset all settings to their default values. Your custom configurations will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={handleReset}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+108 -70
View File
@@ -1,86 +1,124 @@
import { Home, Settings, Bug, Activity, LayoutGrid } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HomeIcon } from "@/components/ui/home";
import { HistoryIcon } from "@/components/ui/history-icon";
import { SettingsIcon } from "@/components/ui/settings";
import { ActivityIcon } from "@/components/ui/activity";
import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon } from "@/components/ui/file-music";
import { FilePenIcon } from "@/components/ui/file-pen";
import { CoffeeIcon } from "@/components/ui/coffee";
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
currentPage: PageType;
onPageChange: (page: PageType) => void;
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const navItems = [
{ id: "main" as PageType, icon: Home, label: "Home" },
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
];
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1">
return (
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1">
{navItems.map((item) => (
<Tooltip key={item.id} delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === item.id ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange(item.id)}
>
<item.icon className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
))}
</div>
{/* GitHub - below debug */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
<HomeIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Report Bug</p>
<p>Home</p>
</TooltipContent>
</Tooltip>
{/* Other Projects at bottom */}
<div className="mt-auto">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://exyezed.cc/")}
>
<LayoutGrid className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Other Projects</p>
</TooltipContent>
</Tooltip>
</div>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
<HistoryIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>History</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
<ActivityIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
<FileMusicIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Audio Converter</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
<FilePenIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>File Manager</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
<TerminalIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Debug Logs</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
<SettingsIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</Tooltip>
</div>
);
<div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
<BadgeAlertIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>About</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Every coffee helps me keep going</p>
</TooltipContent>
</Tooltip>
</div>
</div>);
}
+188 -284
View File
@@ -1,289 +1,193 @@
import { useEffect, useRef } from "react";
import type { SpectrumData } from "@/types/api";
interface SpectrumVisualizationProps {
sampleRate: number;
bitsPerSample: number;
duration: number;
spectrumData?: SpectrumData;
sampleRate: number;
bitsPerSample: number;
duration: number;
spectrumData?: SpectrumData;
}
export function SpectrumVisualization({
sampleRate,
bitsPerSample,
duration,
spectrumData,
}: SpectrumVisualizationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// Calculate margins for labels
const marginLeft = 70; // More space for Frequency label
const marginRight = 70; // Space for color bar
const marginTop = 30; // More space at top
const marginBottom = 65; // More space at bottom for Time label
const plotWidth = width - marginLeft - marginRight;
const plotHeight = height - marginTop - marginBottom;
// Black background
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
// Calculate Nyquist frequency
const nyquistFreq = sampleRate / 2;
if (spectrumData) {
drawRealSpectrum(
ctx,
marginLeft,
marginTop,
plotWidth,
plotHeight,
spectrumData
);
}
// Draw axes, labels, and color bar
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
}, [sampleRate, bitsPerSample, duration, spectrumData]);
const drawRealSpectrum = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
spectrum: SpectrumData
) => {
const timeSlices = spectrum.time_slices;
if (timeSlices.length === 0) return;
const freqBins = timeSlices[0].magnitudes.length;
const nyquistFreq = spectrum.max_freq;
// Find min/max dB values
let minDB = 0;
let maxDB = -200;
timeSlices.forEach((slice) => {
slice.magnitudes.forEach((db) => {
if (db > maxDB) maxDB = db;
if (db < minDB && db > -200) minDB = db;
});
});
// Clamp range for better visualization
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
const dbRange = maxDB - minDB;
const sliceWidth = Math.ceil(width / timeSlices.length);
for (let t = 0; t < timeSlices.length; t++) {
const slice = timeSlices[t];
const xPos = x + (t / timeSlices.length) * width;
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
const db = slice.magnitudes[f];
// Linear frequency scale
const freq = (f / freqBins) * nyquistFreq;
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
// Calculate bin height
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
const nextFreqRatio = nextFreq / nyquistFreq;
const nextYPos = y + height - (nextFreqRatio * height);
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
// Normalize intensity
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
}
}
};
// Vibrant color scheme like Spek - NGEJERENG!
const getSpekColor = (intensity: number): string => {
if (intensity < 0.08) {
// Black to deep blue
const t = intensity / 0.08;
return `rgb(0, 0, ${Math.floor(t * 80)})`;
} else if (intensity < 0.18) {
// Deep blue to bright blue
const t = (intensity - 0.08) / 0.10;
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
} else if (intensity < 0.28) {
// Blue to magenta/purple
const t = (intensity - 0.18) / 0.10;
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
} else if (intensity < 0.40) {
// Magenta to bright red
const t = (intensity - 0.28) / 0.12;
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
} else if (intensity < 0.52) {
// Red to orange-red
const t = (intensity - 0.40) / 0.12;
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
} else if (intensity < 0.65) {
// Orange-red to bright orange
const t = (intensity - 0.52) / 0.13;
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
} else if (intensity < 0.78) {
// Orange to yellow-orange
const t = (intensity - 0.65) / 0.13;
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
} else if (intensity < 0.90) {
// Yellow-orange to bright yellow
const t = (intensity - 0.78) / 0.12;
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
} else {
// Yellow to white (hottest)
const t = (intensity - 0.90) / 0.10;
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
}
};
const drawAxesAndLabels = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
nyquistFreq: number,
duration: number,
sampleRate: number
) => {
// Frequency labels on Y-axis
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
// Generate frequency labels based on Nyquist
const freqLabels = generateFreqLabels(nyquistFreq);
freqLabels.forEach(freq => {
if (freq <= nyquistFreq) {
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
ctx.fillText(label, x - 8, yPos);
}
});
// "0" at bottom
ctx.fillText("0", x - 8, y + height);
// Time labels on X-axis
ctx.textAlign = "center";
ctx.textBaseline = "top";
const timeStep = getTimeStep(duration);
for (let t = 0; t <= duration; t += timeStep) {
const xPos = x + (t / duration) * width;
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
}
// Axis titles
ctx.fillStyle = "#FFFFFF";
ctx.font = "13px Arial";
// Y-axis title: "Frequency (Hz)"
ctx.save();
ctx.translate(12, y + height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Frequency (Hz)", 0, 0);
ctx.restore();
// X-axis title: "Time (seconds)"
ctx.textAlign = "center";
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
// Sample rate info in top right
ctx.textAlign = "right";
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
};
const generateFreqLabels = (nyquistFreq: number): number[] => {
if (nyquistFreq <= 24000) {
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
} else if (nyquistFreq <= 48000) {
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>
);
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas)
return;
const ctx = canvas.getContext("2d");
if (!ctx)
return;
const width = canvas.width;
const height = canvas.height;
const marginLeft = 70;
const marginRight = 70;
const marginTop = 30;
const marginBottom = 65;
const plotWidth = width - marginLeft - marginRight;
const plotHeight = height - marginTop - marginBottom;
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
const nyquistFreq = sampleRate / 2;
if (spectrumData) {
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
}
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
}, [sampleRate, bitsPerSample, duration, spectrumData]);
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
const timeSlices = spectrum.time_slices;
if (timeSlices.length === 0)
return;
const freqBins = timeSlices[0].magnitudes.length;
const nyquistFreq = spectrum.max_freq;
let minDB = 0;
let maxDB = -200;
timeSlices.forEach((slice) => {
slice.magnitudes.forEach((db) => {
if (db > maxDB)
maxDB = db;
if (db < minDB && db > -200)
minDB = db;
});
});
minDB = Math.max(minDB, maxDB - 90);
const dbRange = maxDB - minDB;
const sliceWidth = Math.ceil(width / timeSlices.length);
for (let t = 0; t < timeSlices.length; t++) {
const slice = timeSlices[t];
const xPos = x + (t / timeSlices.length) * width;
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
const db = slice.magnitudes[f];
const freq = (f / freqBins) * nyquistFreq;
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
const nextFreqRatio = nextFreq / nyquistFreq;
const nextYPos = y + height - (nextFreqRatio * height);
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
}
}
};
const getSpekColor = (intensity: number): string => {
if (intensity < 0.08) {
const t = intensity / 0.08;
return `rgb(0, 0, ${Math.floor(t * 80)})`;
}
else if (intensity < 0.18) {
const t = (intensity - 0.08) / 0.10;
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
}
else if (intensity < 0.28) {
const t = (intensity - 0.18) / 0.10;
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
}
else if (intensity < 0.40) {
const t = (intensity - 0.28) / 0.12;
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
}
else if (intensity < 0.52) {
const t = (intensity - 0.40) / 0.12;
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
}
else if (intensity < 0.65) {
const t = (intensity - 0.52) / 0.13;
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
}
else if (intensity < 0.78) {
const t = (intensity - 0.65) / 0.13;
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
}
else if (intensity < 0.90) {
const t = (intensity - 0.78) / 0.12;
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
}
else {
const t = (intensity - 0.90) / 0.10;
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
}
};
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
const freqLabels = generateFreqLabels(nyquistFreq);
freqLabels.forEach(freq => {
if (freq <= nyquistFreq) {
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
ctx.fillText(label, x - 8, yPos);
}
});
ctx.fillText("0", x - 8, y + height);
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);
}
ctx.fillStyle = "#FFFFFF";
ctx.font = "13px Arial";
ctx.save();
ctx.translate(12, y + height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Frequency (Hz)", 0, 0);
ctx.restore();
ctx.textAlign = "center";
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
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 => {
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) => {
for (let i = 0; i < height; i++) {
const intensity = 1 - (i / height);
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(x, y + i, width, 1);
}
ctx.strokeStyle = "#666666";
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
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 { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
export function TitleBar() {
const handleMinimize = () => {
WindowMinimise();
};
const handleMaximize = () => {
WindowToggleMaximise();
};
const handleClose = () => {
Quit();
};
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}
/>
const handleMinimize = () => {
WindowMinimise();
};
const handleMaximize = () => {
WindowToggleMaximise();
};
const handleClose = () => {
Quit();
};
return (<>
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
{/* Window control buttons - Windows style, right side */}
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
<button
onClick={handleMinimize}
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Minimize"
>
<Minus className="w-3.5 h-3.5" />
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
<Minus className="w-3.5 h-3.5"/>
</button>
<button
onClick={handleMaximize}
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Maximize"
>
<Maximize className="w-3.5 h-3.5" />
<button onClick={handleMaximize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Maximize">
<Maximize className="w-3.5 h-3.5"/>
</button>
<button
onClick={handleClose}
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Close"
>
<X className="w-3.5 h-3.5" />
<button onClick={handleClose} className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Close">
<X className="w-3.5 h-3.5"/>
</button>
</div>
</>
);
</>);
}
+139 -186
View File
@@ -1,205 +1,158 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, DeezerIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview";
interface TrackInfoProps {
track: TrackMetadata & { album_name: string; release_date: string };
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
isFailed: boolean;
isSkipped: boolean;
downloadingLyricsTrack?: string | null;
downloadedLyrics?: boolean;
failedLyrics?: boolean;
skippedLyrics?: boolean;
checkingAvailability?: boolean;
availability?: TrackAvailability;
downloadingCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => void;
onOpenFolder: () => void;
track: TrackMetadata & {
album_name: string;
release_date: string;
};
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
isFailed: boolean;
isSkipped: boolean;
downloadingLyricsTrack?: string | null;
downloadedLyrics?: boolean;
failedLyrics?: boolean;
skippedLyrics?: boolean;
checkingAvailability?: boolean;
availability?: TrackAvailability;
downloadingCover?: boolean;
downloadedCover?: boolean;
failedCover?: boolean;
skippedCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, 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;
onBack?: () => void;
}
export function TrackInfo({
track,
isDownloading,
downloadingTrack,
isDownloaded,
isFailed,
isSkipped,
downloadingLyricsTrack,
downloadedLyrics,
failedLyrics,
skippedLyrics,
checkingAvailability,
availability,
downloadingCover,
onDownload,
onDownloadLyrics,
onCheckAvailability,
onDownloadCover,
onOpenFolder,
}: TrackInfoProps) {
const [isHoveringCover, setIsHoveringCover] = useState(false);
return (
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
<div
className="shrink-0 relative"
onMouseEnter={() => setIsHoveringCover(true)}
onMouseLeave={() => setIsHoveringCover(false)}
>
{track.images && (
<>
<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 className="flex-1 space-y-4 min-w-0">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isSkipped ? (
<FileCheck className="h-6 w-6 text-yellow-500 shrink-0" />
) : isDownloaded ? (
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
) : isFailed ? (
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
) : null}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const formatPlays = (plays: string) => {
const num = parseInt(plays, 10);
if (isNaN(num))
return plays;
return num.toLocaleString();
};
return (<Card className="relative">
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack}>
<XCircle className="h-5 w-5"/>
</Button>
</div>)}
<CardContent className="px-6">
<div className="flex gap-6 items-start">
<div className="shrink-0">
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
{formatDuration(track.duration_ms)}
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
</div>)}
</div>
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{track.album_name}</p>
</div>
{track.plays && (<div>
<p className="text-xs text-muted-foreground">Total Plays</p>
<p className="font-medium">{formatPlays(track.plays)}</p>
</div>)}
</div>
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Release Date</p>
<p className="font-medium">{track.release_date}</p>
</div>
{track.copyright && (<div>
<p className="text-xs text-muted-foreground">Copyright</p>
<p className="font-medium truncate" title={track.copyright}>
{track.copyright}
</p>
</div>)}
</div>
{track.isrc && (
<div className="flex gap-2 flex-wrap">
<Button
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)}
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
<Download className="h-4 w-4" />
Download
</>
)}
</Button>
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
{downloadingLyricsTrack === track.spotify_id ? (
<Spinner />
) : skippedLyrics ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedLyrics ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<FileText className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
variant="outline"
disabled={checkingAvailability}
>
{checkingAvailability ? (
<Spinner />
) : availability ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent>
</Tooltip>
)}
{isDownloaded && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
)}
</div>
)}
</div>
{track.isrc && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
<Download className="h-4 w-4"/>
Download
</>)}
</Button>
{track.spotify_id && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => playPreview(track.spotify_id!, track.name)} variant="outline" size="icon" disabled={loadingPreview === track.spotify_id}>
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingCover}>
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" size="icon" disabled={checkingAvailability}>
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>)}
</div>)}
</div>
</CardContent>
</Card>
);
</div>
</CardContent>
</Card>);
}
+355 -472
View File
@@ -1,488 +1,371 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, DeezerIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
import { usePreview } from "@/hooks/usePreview";
interface TrackListProps {
tracks: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
currentPage: number;
itemsPerPage: number;
showCheckboxes?: boolean;
hideAlbumColumn?: boolean;
folderName?: string;
isArtistDiscography?: boolean;
// Lyrics props
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onPageChange: (page: number) => void;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
onTrackClick?: (track: TrackMetadata) => void;
tracks: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
currentPage: number;
itemsPerPage: number;
showCheckboxes?: boolean;
hideAlbumColumn?: boolean;
folderName?: string;
isArtistDiscography?: boolean;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onPageChange: (page: number) => void;
onAlbumClick?: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
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) {
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);
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
let filteredTracks = tracks.filter((track) => {
if (!searchQuery)
return true;
const query = searchQuery.toLowerCase();
return (track.name.toLowerCase().includes(query) ||
track.artists.toLowerCase().includes(query) ||
track.album_name.toLowerCase().includes(query));
});
} 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);
});
}
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 === "plays-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
if (isNaN(aPlays))
return 1;
if (isNaN(bPlays))
return -1;
return aPlays - bPlays;
});
}
else if (sortBy === "plays-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
if (isNaN(aPlays))
return 1;
if (isNaN(bPlays))
return -1;
return bPlays - aPlays;
});
}
else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
});
}
else if (sortBy === "not-downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
});
}
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
if (total <= 10) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | 'ellipsis')[] = [];
pages.push(1);
if (current <= 7) {
for (let i = 2; i <= 10; i++) {
pages.push(i);
}
pages.push('ellipsis');
pages.push(total);
}
else if (current >= total - 7) {
pages.push('ellipsis');
for (let i = total - 9; i <= total; i++) {
pages.push(i);
}
}
else {
pages.push('ellipsis');
pages.push(current - 1);
pages.push(current);
pages.push(current + 1);
pages.push('ellipsis');
pages.push(total);
}
return pages;
};
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
const allSelected = tracksWithIsrc.length > 0 &&
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="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
</th>)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
#
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title
</th>
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
Album
</th>)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
Duration
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
Plays
</th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
Actions
</th>
</tr>
</thead>
<tbody>
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (<td className="p-4 align-middle">
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground">
<div className="flex flex-col items-center gap-0.5">
<span>{startIndex + index + 1}</span>
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
? "text-green-500"
: track.status === "DOWN"
? "text-red-500"
: track.status === "NEW"
? "text-blue-500"
: ""}`}>
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
</span>)}
</div>
</td>
<td className="p-4 align-middle">
<div className="flex items-center gap-3">
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
{track.name}
</span>) : (<span className="font-medium">{track.name}</span>)}
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
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")}`;
};
return (
<div className="space-y-4">
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
{showCheckboxes && (
<th className="h-12 px-4 text-left align-middle w-12">
<Checkbox
checked={allSelected}
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
/>
</th>
)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
#
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title
</th>
{!hideAlbumColumn && (
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
Album
</th>
)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
Duration
</th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
Actions
</th>
</tr>
</thead>
<tbody>
{paginatedTracks.map((track, index) => (
<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (
<td className="p-4 align-middle">
{track.isrc && (
<Checkbox
checked={selectedTracks.includes(track.isrc)}
onCheckedChange={() => onToggleTrack(track.isrc)}
/>
)}
</td>
)}
<td className="p-4 align-middle text-sm text-muted-foreground">
{startIndex + index + 1}
</td>
<td className="p-4 align-middle">
<div className="flex items-center gap-3">
{track.images && (
<img
src={track.images}
alt={track.name}
className="w-10 h-10 rounded object-cover"
/>
)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
{onTrackClick ? (
<span
className="font-medium cursor-pointer hover:underline"
onClick={() => onTrackClick(track)}
>
{track.name}
</span>
) : (
<span className="font-medium">{track.name}</span>
)}
{skippedTracks.has(track.isrc) ? (
<FileCheck className="h-4 w-4 text-yellow-500 shrink-0" />
) : downloadedTracks.has(track.isrc) ? (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
) : failedTracks.has(track.isrc) ? (
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
) : null}
</div>
<span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? (
track.artists_data.map((artist, i, arr) => (
<span key={artist.id}>
{onArtistClick ? (
<span
className="cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})
}
>
{artist.name}
</span>
) : (
artist.name
)}
{i < arr.length - 1 && ", "}
</span>
))
) : onArtistClick && track.artist_id && track.artist_url ? (
<span
className="cursor-pointer hover:underline"
onClick={() =>
onArtistClick({
id: track.artist_id!,
name: track.artists,
external_urls: track.artist_url!,
})
}
>
{track.artists}
</span>
) : (
track.artists
)}
</span>
</div>
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
</div>
</td>
{!hideAlbumColumn && (
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
{onAlbumClick && track.album_id && track.album_url ? (
<span
className="cursor-pointer hover:underline"
onClick={() =>
onAlbumClick({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})
}
>
{track.album_name}
</span>
) : (
track.album_name
)}
</td>
)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
{formatDuration(track.duration_ms)}
</td>
<td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1">
{track.isrc && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1)
}
size="sm"
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : skippedTracks.has(track.isrc) ? (
<FileCheck className="h-4 w-4" />
) : downloadedTracks.has(track.isrc) ? (
<CheckCircle className="h-4 w-4" />
) : failedTracks.has(track.isrc) ? (
<XCircle className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{downloadingTrack === track.isrc ? (
<p>Downloading...</p>
) : skippedTracks.has(track.isrc) ? (
<p>Already exists</p>
) : downloadedTracks.has(track.isrc) ? (
<p>Downloaded</p>
) : failedTracks.has(track.isrc) ? (
<p>Failed</p>
) : (
<p>Download Track</p>
)}
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1)
}
size="sm"
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
{downloadingLyricsTrack === track.spotify_id ? (
<Spinner />
) : skippedLyrics?.has(track.spotify_id) ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedLyrics?.has(track.spotify_id) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics?.has(track.spotify_id) ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<FileText className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>
)}
{track.images && onDownloadCover && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
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);
}}
size="sm"
variant="outline"
disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}
>
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (
<Spinner />
) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<ImageDown className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
size="sm"
variant="outline"
disabled={checkingAvailabilityTrack === track.spotify_id}
>
{checkingAvailabilityTrack === track.spotify_id ? (
<Spinner />
) : availabilityMap?.has(track.spotify_id) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availabilityMap?.has(track.spotify_id) ? (
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} />
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent>
</Tooltip>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? ((() => {
const artistNames = track.artists.split(", ").map(name => name.trim());
return artistNames.map((name, i) => {
const artistData = track.artists_data![i];
const hasArtistData = artistData && artistData.id && artistData.external_urls;
return (<span key={artistData?.id || i}>
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artistData.id,
name: name,
external_urls: artistData.external_urls,
})}>
{name}
</span>) : (name)}
{i < artistNames.length - 1 && ", "}
</span>);
});
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: track.artist_id!,
name: track.artists,
external_urls: track.artist_url!,
})}>
{track.artists}
</span>) : (track.artists)}
</span>
</div>
</div>
</td>
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})}>
{track.album_name}
</span>) : (track.album_name)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
{formatDuration(track.duration_ms)}
</td>
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
{track.plays ? formatPlays(track.plays) : ""}
</td>
<td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1">
{track.isrc && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
</TooltipContent>
</Tooltip>)}
{track.spotify_id && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => playPreview(track.spotify_id!, track.name)} size="icon" variant="outline" disabled={loadingPreview === track.spotify_id}>
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="icon" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => {
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
}} size="icon" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
</div>
</td>
</tr>))}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage > 1) onPageChange(currentPage - 1);
}}
className={
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
{totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
onPageChange(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
onClick={(e) => {
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
onPageChange(page);
}}
isActive={currentPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
}} isActive={currentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>)))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages) onPageChange(currentPage + 1);
}}
className={
currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
onPageChange(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"}/>
</PaginationItem>
</PaginationContent>
</Pagination>)}
</div>);
}
+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 ActivityIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
pathOffset: 0,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
pathOffset: [1, 0],
transition: {
duration: 0.8,
ease: 'easeInOut',
},
},
};
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
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="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
ActivityIcon.displayName = 'ActivityIcon';
export { ActivityIcon };
@@ -0,0 +1,61 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface BadgeAlertIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ICON_VARIANTS: Variants = {
normal: { scale: 1, rotate: 0 },
animate: {
scale: [1, 1.1, 1.1, 1.1, 1],
rotate: [0, -3, 3, -2, 2, 0],
transition: {
duration: 0.5,
times: [0, 0.2, 0.4, 0.6, 1],
ease: "easeInOut",
},
},
};
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ 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) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
<line x1="12" x2="12" y1="8" y2="12"/>
<line x1="12" x2="12.01" y1="16" y2="16"/>
</motion.svg>
</div>);
});
BadgeAlertIcon.displayName = "BadgeAlertIcon";
export { BadgeAlertIcon };
+19 -41
View File
@@ -1,46 +1,24 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", {
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
});
function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "span";
return (<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props}/>);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+30 -55
View File
@@ -1,60 +1,35 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", {
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "h-9 w-9 p-0",
"icon-sm": "h-8 w-8 p-0",
"icon-lg": "h-10 w-10 p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
});
function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props}/>);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+10 -78
View File
@@ -1,92 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
return (<div data-slot="card" className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} {...props}/>);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
return (<div data-slot="card-header" className={cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)} {...props}/>);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
return (<div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props}/>);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
return (<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
return (<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props}/>);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
return (<div data-slot="card-content" className={cn("px-6", className)} {...props}/>);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
return (<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props}/>);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, };
+11 -30
View File
@@ -1,32 +1,13 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (<CheckboxPrimitive.Root data-slot="checkbox" className={cn("peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none">
<CheckIcon className="size-3.5"/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
</CheckboxPrimitive.Root>);
}
export { Checkbox }
export { Checkbox };
+67
View File
@@ -0,0 +1,67 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface CoffeeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
loop?: boolean;
}
const PATH_VARIANTS: Variants = {
normal: {
y: 0,
opacity: 1,
},
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, loop = false, ...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" style={{ overflow: 'visible' }}>
<motion.path d="M10 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.2}/>
<motion.path d="M14 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.4}/>
<motion.path d="M6 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0}/>
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
</svg>
</div>);
});
CoffeeIcon.displayName = 'CoffeeIcon';
export { CoffeeIcon };
+48 -223
View File
@@ -1,252 +1,77 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props}/>;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props}/>);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props}/>);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props}/>);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props}/>;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (<ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props}/>);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
return (<ContextMenuPrimitive.SubTrigger data-slot="context-menu-sub-trigger" data-inset={inset} className={cn("focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
<ChevronRightIcon className="ml-auto"/>
</ContextMenuPrimitive.SubTrigger>);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (<ContextMenuPrimitive.SubContent data-slot="context-menu-sub-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", className)} {...props}/>);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content data-slot="context-menu-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", className)} {...props}/>
</ContextMenuPrimitive.Portal>);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
return (<ContextMenuPrimitive.Item data-slot="context-menu-item" data-inset={inset} data-variant={variant} className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}/>);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (<ContextMenuPrimitive.CheckboxItem data-slot="context-menu-checkbox-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<CheckIcon className="size-4"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
</ContextMenuPrimitive.CheckboxItem>);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (<ContextMenuPrimitive.RadioItem data-slot="context-menu-radio-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
<CircleIcon className="size-2 fill-current"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
</ContextMenuPrimitive.RadioItem>);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
className
)}
{...props}
/>
)
return (<ContextMenuPrimitive.Label data-slot="context-menu-label" data-inset={inset} className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8", className)} {...props}/>);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (<ContextMenuPrimitive.Separator data-slot="context-menu-separator" className={cn("bg-border -mx-1 my-1 h-px", className)} {...props}/>);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (<span data-slot="context-menu-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props}/>);
}
export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };
+29 -125
View File
@@ -1,143 +1,47 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props}/>;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}/>;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props}/>;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props}/>;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (<DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn("data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className)} {...props}/>);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
return (<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
className
)}
{...props}
>
<DialogPrimitive.Content data-slot="dialog-content" className={cn("bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200", className)} {...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
{showCloseButton && (<DialogPrimitive.Close data-slot="dialog-close" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Close>)}
</DialogPrimitive.Content>
</DialogPortal>
)
</DialogPortal>);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
return (<div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props}/>);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
return (<div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props}/>);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (<DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props}/>);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
+64
View File
@@ -0,0 +1,64 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FileMusicIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M8 20v-7l3 1.474" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.circle cx="6" cy="20" r="2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FileMusicIcon.displayName = 'FileMusicIcon';
export { FileMusicIcon };
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FilePenIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FilePenIcon.displayName = 'FilePenIcon';
export { FilePenIcon };
@@ -0,0 +1,97 @@
"use client";
import type { Transition, Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface HistoryIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HistoryIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ARROW_TRANSITION: Transition = {
type: "spring",
stiffness: 250,
damping: 25,
};
const ARROW_VARIANTS: Variants = {
normal: {
rotate: "0deg",
},
animate: {
rotate: "-50deg",
},
};
const HAND_TRANSITION: Transition = {
duration: 0.6,
ease: [0.4, 0, 0.2, 1],
};
const HAND_VARIANTS: Variants = {
normal: {
rotate: 0,
originX: "0%",
originY: "100%",
},
animate: {
rotate: -360,
originX: "0%",
originY: "100%",
},
};
const MINUTE_HAND_TRANSITION: Transition = {
duration: 0.5,
ease: "easeInOut",
};
const MINUTE_HAND_VARIANTS: Variants = {
normal: {
rotate: 0,
originX: "0%",
originY: "0%",
},
animate: {
rotate: -45,
originX: "0%",
originY: "0%",
},
};
const HistoryIcon = forwardRef<HistoryIconHandle, HistoryIconProps>(({ 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) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<motion.g animate={controls} transition={ARROW_TRANSITION} variants={ARROW_VARIANTS}>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</motion.g>
<motion.line animate={controls} initial="normal" transition={HAND_TRANSITION} variants={HAND_VARIANTS} x1="12" x2="12" y1="12" y2="7"/>
<motion.line animate={controls} initial="normal" transition={MINUTE_HAND_TRANSITION} variants={MINUTE_HAND_VARIANTS} x1="12" x2="16" y1="12" y2="14"/>
</svg>
</div>);
});
HistoryIcon.displayName = "HistoryIcon";
export { HistoryIcon };
+62
View File
@@ -0,0 +1,62 @@
'use client';
import type { Transition, Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface HomeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DEFAULT_TRANSITION: Transition = {
duration: 0.6,
opacity: { duration: 0.2 },
};
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
},
};
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
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">
<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>
</div>);
});
HomeIcon.displayName = 'HomeIcon';
export { HomeIcon };
+121 -181
View File
@@ -1,216 +1,156 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu";
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
export interface InputWithContextProps
extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void;
export interface InputWithContextProps extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void;
}
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(
({ className, type, onValueChange, onChange, ...props }, ref) => {
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(({ className, type, onValueChange, onChange, ...props }, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [hasSelection, setHasSelection] = React.useState(false);
const [canPaste, setCanPaste] = React.useState(false);
// Combine refs
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
// Check selection state
const updateSelectionState = () => {
const input = inputRef.current;
if (!input) return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
setHasSelection(start !== end);
};
// Check clipboard permission when user explicitly opens the context menu.
const checkClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
setCanPaste(text.length > 0);
} catch {
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 input = inputRef.current;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const newValue =
input.value.substring(0, start) + text + input.value.substring(end);
// Update value and trigger change
input.value = newValue;
const newPosition = start + text.length;
input.setSelectionRange(newPosition, newPosition);
// Trigger React onChange
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);
}
setHasSelection(start !== end);
};
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);
input.value = newValue;
input.setSelectionRange(start, start);
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 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 input = inputRef.current;
if (!input) return;
input.select();
input.focus();
updateSelectionState();
const input = inputRef.current;
if (!input)
return;
input.select();
input.focus();
updateSelectionState();
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
};
return (
<ContextMenu
onOpenChange={(open) => {
if (open) {
checkClipboard();
}
}}
>
return (<ContextMenu onOpenChange={(open) => {
if (open) {
checkClipboard();
}
}}>
<ContextMenuTrigger asChild>
<Input
ref={inputRef}
type={type}
className={className}
onChange={handleInputChange}
onSelect={updateSelectionState}
onMouseUp={updateSelectionState}
onKeyUp={updateSelectionState}
{...props}
/>
<Input ref={inputRef} type={type} className={className} onChange={handleInputChange} onSelect={updateSelectionState} onMouseUp={updateSelectionState} onKeyUp={updateSelectionState} {...props}/>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
onSelect={handleCut}
disabled={!hasSelection || props.disabled || props.readOnly}
>
<Scissors className="mr-2 h-4 w-4" />
<ContextMenuItem onSelect={handleCut} disabled={!hasSelection || props.disabled || props.readOnly}>
<Scissors className="mr-2 h-4 w-4"/>
Cut
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={handleCopy}
disabled={!hasSelection || props.disabled}
>
<Copy className="mr-2 h-4 w-4" />
<ContextMenuItem onSelect={handleCopy} disabled={!hasSelection || props.disabled}>
<Copy className="mr-2 h-4 w-4"/>
Copy
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={handlePaste}
disabled={!canPaste || props.disabled || props.readOnly}
>
<Clipboard className="mr-2 h-4 w-4" />
<ContextMenuItem onSelect={handlePaste} disabled={!canPaste || props.disabled || props.readOnly}>
<Clipboard className="mr-2 h-4 w-4"/>
Paste
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={handleSelectAll}
disabled={!inputRef.current?.value || props.disabled}
>
<Type className="mr-2 h-4 w-4" />
<ContextMenuItem onSelect={handleSelectAll} disabled={!inputRef.current?.value || props.disabled}>
<Type className="mr-2 h-4 w-4"/>
Select All
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
);
</ContextMenu>);
});
InputWithContext.displayName = "InputWithContext";
export { InputWithContext };
+4 -19
View File
@@ -1,21 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
return (<input type={type} data-slot="input" className={cn("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className)} {...props}/>);
}
export { Input }
export { Input };
+7 -23
View File
@@ -1,24 +1,8 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (<LabelPrimitive.Root data-slot="label" className={cn("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className)} {...props}/>);
}
export { Label }
export { Label };
+26 -112
View File
@@ -1,127 +1,41 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
return (<nav role="navigation" aria-label="pagination" data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...props}/>);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return (<ul data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)} {...props}/>);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
return <li data-slot="pagination-item" {...props}/>;
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> & React.ComponentProps<"a">;
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
return (<a aria-current={isActive ? "page" : undefined} data-slot="pagination-link" data-active={isActive} className={cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}), className)} {...props}/>);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 px-2.5 sm:pl-2.5", className)} {...props}>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
</PaginationLink>);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 px-2.5 sm:pr-2.5", className)} {...props}>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
</PaginationLink>);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (<span aria-hidden data-slot="pagination-ellipsis" className={cn("flex size-9 items-center justify-center", className)} {...props}>
<MoreHorizontalIcon className="size-4"/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
</span>);
}
export { Pagination, PaginationContent, PaginationLink, PaginationItem, PaginationPrevious, PaginationNext, PaginationEllipsis, };
+9 -30
View File
@@ -1,31 +1,10 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (<ProgressPrimitive.Root data-slot="progress" className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)} {...props}>
<ProgressPrimitive.Indicator data-slot="progress-indicator" className="bg-primary h-full w-full flex-1 transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }}/>
</ProgressPrimitive.Root>);
}
export { Progress }
export { Progress };
@@ -1,45 +0,0 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-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 dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+13 -41
View File
@@ -1,46 +1,18 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>>(({ className, children, ...props }, ref) => (<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
</ScrollAreaPrimitive.Root>));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>>(({ className, orientation = "vertical", ...props }, ref) => (<ScrollAreaPrimitive.ScrollAreaScrollbar ref={ref} orientation={orientation} className={cn("flex touch-none select-none transition-colors", orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]", orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]", className)} {...props}>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border"/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+39 -161
View File
@@ -1,185 +1,63 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props}/>;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props}/>;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props}/>;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
function SelectTrigger({ className, size = "default", children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
return (<SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn("border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
<ChevronDownIcon className="size-4 opacity-50"/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
</SelectPrimitive.Trigger>);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
function SelectContent({ className, children, position = "popper", align = "center", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (<SelectPrimitive.Portal>
<SelectPrimitive.Content data-slot="select-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className)} position={position} align={align} {...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
)}
>
<SelectPrimitive.Viewport className={cn("p-1", position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
</SelectPrimitive.Portal>);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
</SelectPrimitive.Item>);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (<SelectPrimitive.Separator data-slot="select-separator" className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props}/>);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (<SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronUpIcon className="size-4"/>
</SelectPrimitive.ScrollUpButton>);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (<SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronDownIcon className="size-4"/>
</SelectPrimitive.ScrollDownButton>);
}
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, };
+54
View File
@@ -0,0 +1,54 @@
'use client';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface SettingsIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const 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}>
<motion.svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" transition={{ type: 'spring', stiffness: 50, damping: 10 }} variants={{
normal: {
rotate: 0,
},
animate: {
rotate: 180,
},
}} animate={controls}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</motion.svg>
</div>);
});
SettingsIcon.displayName = 'SettingsIcon';
export { SettingsIcon };
+26 -46
View File
@@ -1,47 +1,27 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, } from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
toastOptions={{
classNames: {
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
},
}}
style={
{
"--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 }
const { theme = "system" } = useTheme();
return (<Sonner theme={theme as ToasterProps["theme"]} className="toaster group" icons={{
success: <CircleCheckIcon className="size-4"/>,
info: <InfoIcon className="size-4"/>,
warning: <TriangleAlertIcon className="size-4"/>,
error: <OctagonXIcon className="size-4"/>,
loading: <Loader2Icon className="size-4 animate-spin"/>,
}} toastOptions={{
classNames: {
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
},
}} style={{
"--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 { cn } from "@/lib/utils"
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
return (<Loader2 role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props}/>);
}
export { Spinner }
export { Spinner };

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