Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f13359df7f | |||
| ff14990bd8 | |||
| 0e62356183 | |||
| acec3c350e | |||
| fc520c1cc4 | |||
| 6137995fea | |||
| ae2e4eb155 | |||
| eb468b16df | |||
| 1be4d825fd | |||
| 01d039947a | |||
| 066b6bcbdb | |||
| b3273b7602 | |||
| d495a9851c | |||
| 6f5fd1d16e | |||
| f4b7049f4a | |||
| 4cccdcae77 | |||
| c21d08f050 | |||
| 00d3fb9212 | |||
| 7b12866334 | |||
| 1b415961cc | |||
| 74001462b4 | |||
| fdca1ab461 | |||
| 3d8ff2cedd | |||
| 9ef24f5a91 | |||
| 1314c14c59 | |||
| cb3a6a32cb | |||
| df56049db2 | |||
| 36a77ad8d1 | |||
| 71bce5d33e | |||
| b74dec7369 | |||
| d5c5f34d4c | |||
| 27be5c1b91 | |||
| 0c41d72ab2 | |||
| 25233349b9 | |||
| e04f6e4fdd | |||
| 24bcc56a8f | |||
| 45ad82bb66 | |||
| 13fcb5787d | |||
| 556e720574 | |||
| 791553bdc0 | |||
| 9361c608ca | |||
| 12729e2ca1 | |||
| b620112886 | |||
| cc1c80d367 | |||
| 63149c91a2 | |||
| 1e99d8b5c6 | |||
| b160d3c790 | |||
| d9cf5a5361 | |||
| 4f135f1153 | |||
| 4ee252f438 | |||
| 2fc08de757 | |||
| 6e3ca48d3f | |||
| 46a7777698 | |||
| 0f2174bf80 | |||
| 36fb34dc63 | |||
| 7f859db173 | |||
| 6e66105481 | |||
| 85b542983e | |||
| ecc6fd961a | |||
| 9260adc2d2 | |||
| cb6dfc1638 | |||
| 5dacd70287 | |||
| b163355c50 | |||
| 58495dd9fd | |||
| 1eb8a5ac0c | |||
| 452cd9e118 | |||
| 1345ac25f4 | |||
| ae8b610462 | |||
| 14297171be | |||
| 6f6c7563a0 | |||
| a52c2bb658 | |||
| 2ce400a5f7 | |||
| b8fd2d1762 | |||
| d2af0d11df | |||
| 57640d85d2 | |||
| d7b0ca8b3c | |||
| 8e6a1196b5 | |||
| c150124273 |
@@ -0,0 +1 @@
|
||||
ko_fi: afkarxyz
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Bug Report
|
||||
description: Bug Report
|
||||
title: "[Bug Report] "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **WARNING: Issues that do not follow this template will be deleted without review.**
|
||||
>
|
||||
> **Please keep `[Bug Report]` in the issue title and only continue after it.**
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
placeholder: e.g. Downloading a playlist stops after the first track with no error message.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: Type
|
||||
description: Select the Spotify item type related to this bug.
|
||||
options:
|
||||
- Track
|
||||
- Album
|
||||
- Playlist
|
||||
- Artist
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: spotify-url
|
||||
attributes:
|
||||
label: Spotify URL
|
||||
placeholder: e.g. https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
placeholder: e.g. Happens every time on this link. Screenshot or recording attached.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Environment"
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SpotiDownloader Version
|
||||
placeholder: e.g. v7.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: OS
|
||||
placeholder: e.g. Windows 11 23H2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Location
|
||||
placeholder: e.g. Indonesia
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Feature Request
|
||||
description: Feature Request
|
||||
title: "[Feature Request] "
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **WARNING: Issues that do not follow this template will be deleted without review.**
|
||||
>
|
||||
> **Please keep `[Feature Request]` in the issue title and only continue after it.**
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
placeholder: e.g. Add an option to choose the output naming format for downloaded tracks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
placeholder: e.g. I want downloaded files to follow a custom format like Artist - Title for easier library management.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
placeholder: e.g. Similar tools allow custom naming templates. Screenshot or mockup attached if needed.
|
||||
validations:
|
||||
required: true
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.25.5'
|
||||
GO_VERSION: '1.26'
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -61,4 +61,4 @@ test
|
||||
|
||||
# Build notes (optional - uncomment if you don't want to commit)
|
||||
# BUILD_NOTES.md
|
||||
build.txt
|
||||
push.bat
|
||||
@@ -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.
|
||||
@@ -1,27 +1,121 @@
|
||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||
# SpotiFLAC
|
||||
|
||||

|
||||
|
||||
<div align="center">
|
||||
<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>
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||
|
||||
## Screenshot
|
||||

|
||||
|
||||

|
||||
## Other projects
|
||||
|
||||
## Other project
|
||||
### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next)
|
||||
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.
|
||||
|
||||
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
|
||||
Get Spotify tracks, albums, playlists and discography in MP3 and FLAC.
|
||||
|
||||
### [SpotubeDL](https://spotubedl.com)
|
||||
|
||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
|
||||
|
||||
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
||||
|
||||
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||
|
||||
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
|
||||
|
||||
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
|
||||
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary>Is this software free?</summary>
|
||||
|
||||
_Yes. This software is completely free.
|
||||
You do not need an account, login, or subscription.
|
||||
All you need is an internet connection._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Can using this software get my Spotify account suspended or banned?</summary>
|
||||
|
||||
_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._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Where does the audio come from?</summary>
|
||||
|
||||
_The audio is fetched using third-party APIs._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Why does metadata fetching sometimes fail?</summary>
|
||||
|
||||
_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._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Why does Windows Defender or antivirus flag or delete the file?</summary>
|
||||
|
||||
_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._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Want to support the project?</summary>
|
||||
|
||||
_If this software is useful and brings you value,
|
||||
consider supporting the project by buying me a coffee.
|
||||
Your support helps keep development going._
|
||||
|
||||
</details>
|
||||
|
||||
[](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
|
||||
|
||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
[](https://repostars.dev/?repos=afkarxyz%2FSpotiFLAC&theme=forest)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -16,32 +14,13 @@ 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 {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `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 AmazonStreamResponse struct {
|
||||
StreamURL string `json:"streamUrl"`
|
||||
DecryptionKey string `json:"decryptionKey"`
|
||||
}
|
||||
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
@@ -49,340 +28,198 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
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
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body first to handle encoding issues and provide better error messages
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
client := NewSongLinkClient()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
var songLinkResp SongLinkResponse
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
|
||||
if !ok || amazonLink.URL == "" {
|
||||
amazonURL := normalizeAmazonMusicURL(urls.AmazonURL)
|
||||
if amazonURL == "" {
|
||||
return "", fmt.Errorf("amazon Music link not found")
|
||||
}
|
||||
|
||||
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 {
|
||||
trackAsin := strings.Split(parts[1], "&")[0]
|
||||
musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=")
|
||||
amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) {
|
||||
var lastError error
|
||||
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, 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)
|
||||
}
|
||||
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
|
||||
asin := asinRegex.FindString(amazonURL)
|
||||
if asin == "" {
|
||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.qzz.io/api/track/%s", asin)
|
||||
req, err := http.NewRequest("GET", apiURL, 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/145.0.0.0 Safari/537.36")
|
||||
|
||||
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var apiResp AmazonStreamResponse
|
||||
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.StreamURL == "" {
|
||||
return "", fmt.Errorf("no stream URL found in response")
|
||||
}
|
||||
|
||||
downloadURL := apiResp.StreamURL
|
||||
fileName := fmt.Sprintf("%s.m4a", asin)
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
||||
dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
dlResp, err := a.client.Do(dlReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
fmt.Printf("Downloading track: %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))
|
||||
|
||||
if apiResp.DecryptionKey != "" {
|
||||
fmt.Printf("Decrypting file...\n")
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
var codec string
|
||||
if err == nil {
|
||||
cmdProbe := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
filePath,
|
||||
)
|
||||
setHideWindow(cmdProbe)
|
||||
codecOutput, _ := cmdProbe.Output()
|
||||
codec = strings.TrimSpace(string(codecOutput))
|
||||
fmt.Printf("Detected codec: %s\n", codec)
|
||||
}
|
||||
|
||||
targetExt := ".m4a"
|
||||
if codec == "flac" {
|
||||
targetExt = ".flac"
|
||||
}
|
||||
|
||||
decryptedFilename := "dec_" + fileName + targetExt
|
||||
|
||||
if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
|
||||
decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
|
||||
}
|
||||
|
||||
decryptedPath := filepath.Join(outputDir, decryptedFilename)
|
||||
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(apiResp.DecryptionKey)
|
||||
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-decryption_key", key,
|
||||
"-i", filePath,
|
||||
"-c", "copy",
|
||||
"-y",
|
||||
decryptedPath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
outStr := string(output)
|
||||
if len(outStr) > 500 {
|
||||
outStr = outStr[len(outStr)-500:]
|
||||
}
|
||||
return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
|
||||
}
|
||||
|
||||
if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
|
||||
return "", fmt.Errorf("decrypted file missing or empty")
|
||||
}
|
||||
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
|
||||
if err := os.Rename(decryptedPath, finalPath); err != nil {
|
||||
return "", fmt.Errorf("failed to rename decrypted file: %w", err)
|
||||
}
|
||||
filePath = finalPath
|
||||
|
||||
fmt.Println("Decryption successful")
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
|
||||
// Create output directory if needed
|
||||
func (a *AmazonDownloader) 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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (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, false)
|
||||
filenameArtist := spotifyArtistName
|
||||
filenameAlbumArtist := spotifyAlbumArtist
|
||||
if useFirstArtistOnly {
|
||||
filenameArtist = GetFirstArtist(spotifyArtistName)
|
||||
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
|
||||
}
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -391,58 +228,125 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
}
|
||||
|
||||
type mbResult struct {
|
||||
ISRC string
|
||||
Metadata Metadata
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
parts := strings.Split(spotifyURL, "/")
|
||||
if len(parts) > 0 {
|
||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||
if sID != "" {
|
||||
client := NewSongLinkClient()
|
||||
if val, err := client.GetISRC(sID); err == nil {
|
||||
isrc = val
|
||||
}
|
||||
}
|
||||
}
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
metaChan <- res
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||
|
||||
// Download from service
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Rename file based on Spotify metadata
|
||||
var isrc string
|
||||
var mbMeta Metadata
|
||||
if spotifyURL != "" {
|
||||
result := <-metaChan
|
||||
isrc = result.ISRC
|
||||
mbMeta = result.Metadata
|
||||
}
|
||||
|
||||
originalFileDir := filepath.Dir(filePath)
|
||||
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
if useFirstArtistOnly {
|
||||
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||
|
||||
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)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
newFilename = newFilename + ".flac"
|
||||
ext := filepath.Ext(filePath)
|
||||
if ext == "" {
|
||||
ext = ".flac"
|
||||
}
|
||||
newFilename = newFilename + ext
|
||||
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 {
|
||||
@@ -451,11 +355,10 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
}
|
||||
|
||||
// Embed Spotify metadata (replace Amazon's embedded metadata)
|
||||
fmt.Println("Embedding Spotify metadata...")
|
||||
|
||||
coverPath := ""
|
||||
// Download Spotify cover (with max resolution if enabled)
|
||||
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = filePath + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
@@ -468,47 +371,60 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
}
|
||||
|
||||
// Determine track number to embed
|
||||
// Use Spotify track number (album track number) if available, otherwise use position
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = position // Fallback to playlist position
|
||||
}
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1 // Default to track 1 for single track downloads without track number
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
// Build metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: spotifyTrackName,
|
||||
Artist: spotifyArtistName,
|
||||
Album: spotifyAlbumName,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: spotifyISRC, // Use ISRC from Spotify
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
ISRC: isrc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
|
||||
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Metadata embedded successfully")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(filePath), ".flac") {
|
||||
|
||||
originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a")
|
||||
if _, err := os.Stat(originalM4aPath); err == nil {
|
||||
if err := os.Remove(originalM4aPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to remove M4A file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
|
||||
// Get Amazon URL from Spotify track ID
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, 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,
|
||||
useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool,
|
||||
) (string, error) {
|
||||
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover)
|
||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
@@ -2,188 +2,114 @@ package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/go-flac/go-flac"
|
||||
mewflac "github.com/mewkiz/flac"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
||||
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"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
Bitrate int `json:"bit_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
}
|
||||
|
||||
// AnalyzeTrack performs audio analysis on a FLAC file
|
||||
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||
if !fileExists(filepath) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
// Get file size
|
||||
fileInfo, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
// Parse FLAC file
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
return GetMetadataWithFFprobe(filepath)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
for _, sample := range samples {
|
||||
absVal := sample
|
||||
if absVal < 0 {
|
||||
absVal = -absVal
|
||||
}
|
||||
if absVal > peak {
|
||||
peak = absVal
|
||||
}
|
||||
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)
|
||||
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return samples, nil
|
||||
}
|
||||
normalized := float64(sample) / maxVal
|
||||
samples = append(samples, normalized)
|
||||
}
|
||||
|
||||
if len(samples) >= maxSamples {
|
||||
for i := 0; i < 5; i++ {
|
||||
if f, err := os.Open(filePath); err == nil {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
func GetFileSize(filepath string) (int64, error) {
|
||||
info, err := os.Stat(filepath)
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
filePath,
|
||||
}
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output))
|
||||
}
|
||||
return info.Size(), nil
|
||||
|
||||
infoMap := make(map[string]string)
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
res := &AnalysisResult{
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
res.FileSize = info.Size()
|
||||
}
|
||||
|
||||
if val, ok := infoMap["sample_rate"]; ok {
|
||||
s, _ := strconv.Atoi(val)
|
||||
res.SampleRate = uint32(s)
|
||||
}
|
||||
if val, ok := infoMap["channels"]; ok {
|
||||
c, _ := strconv.Atoi(val)
|
||||
res.Channels = uint8(c)
|
||||
}
|
||||
if val, ok := infoMap["duration"]; ok {
|
||||
d, _ := strconv.ParseFloat(val, 64)
|
||||
res.Duration = d
|
||||
}
|
||||
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
|
||||
br, _ := strconv.Atoi(val)
|
||||
res.Bitrate = br
|
||||
}
|
||||
|
||||
bits := 0
|
||||
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
|
||||
bits, _ = strconv.Atoi(val)
|
||||
}
|
||||
if bits == 0 {
|
||||
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
|
||||
bits, _ = strconv.Atoi(val)
|
||||
}
|
||||
}
|
||||
|
||||
res.BitsPerSample = uint8(bits)
|
||||
if bits > 0 {
|
||||
res.BitDepth = fmt.Sprintf("%d-bit", bits)
|
||||
} else {
|
||||
res.BitDepth = "Unknown"
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -9,26 +12,31 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "image/jpeg"
|
||||
)
|
||||
|
||||
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,90 +45,113 @@ 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)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
// getMaxResolutionURL converts a Spotify cover URL to max resolution
|
||||
// Falls back to original URL if max resolution is not available
|
||||
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
|
||||
// Try to convert to max resolution
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
// Check if max resolution URL is available
|
||||
resp, err := c.httpClient.Head(maxURL)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
}
|
||||
// Return original URL as fallback
|
||||
return coverURL
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||
|
||||
mediumURL := convertSmallToMedium(imageURL)
|
||||
if strings.Contains(mediumURL, spotifySize640) {
|
||||
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
return mediumURL
|
||||
}
|
||||
|
||||
// DownloadCoverToPath downloads cover art from URL to a specific path
|
||||
// If embedMaxQualityCover is true, it will try to get max resolution
|
||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
// Use max quality URL if setting is enabled
|
||||
downloadURL := coverURL
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if embedMaxQualityCover {
|
||||
downloadURL = c.getMaxResolutionURL(coverURL)
|
||||
downloadURL = c.getMaxResolutionURL(downloadURL)
|
||||
}
|
||||
|
||||
// Download cover image
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %v", err)
|
||||
@@ -131,14 +162,12 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
|
||||
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create file
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write content to file
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %v", err)
|
||||
@@ -147,7 +176,69 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadCover downloads cover art for a single track
|
||||
func (c *CoverClient) ApplyMacOSFLACFileIcon(filePath, coverURL string, iconSize int, embedMaxQualityCover bool) error {
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("file path is required")
|
||||
}
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "spotiflac-file-icon-*.jpg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary cover file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := c.DownloadCoverToPath(coverURL, tmpPath, embedMaxQualityCover); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SetMacOSFileIconFromImage(filePath, tmpPath, iconSize)
|
||||
}
|
||||
|
||||
func ResizeImageForIcon(sourcePath string, iconSize int) (string, error) {
|
||||
if sourcePath == "" {
|
||||
return "", fmt.Errorf("source image path is required")
|
||||
}
|
||||
if iconSize <= 0 {
|
||||
iconSize = 256
|
||||
}
|
||||
|
||||
in, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open source image: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
srcImage, _, err := image.Decode(in)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode source image: %w", err)
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), srcImage, srcImage.Bounds(), xdraw.Over, nil)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "spotiflac-resized-icon-*.png")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create resized icon temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer tmpFile.Close()
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, dst); err != nil {
|
||||
return "", fmt.Errorf("failed to encode resized icon image: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(tmpFile, &encoded); err != nil {
|
||||
return "", fmt.Errorf("failed to write resized icon image: %w", err)
|
||||
}
|
||||
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
return &CoverDownloadResponse{
|
||||
@@ -156,7 +247,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
}, fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
@@ -171,15 +261,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,
|
||||
@@ -189,10 +277,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{
|
||||
@@ -209,7 +295,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{
|
||||
@@ -219,7 +304,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{
|
||||
@@ -234,3 +318,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
previewMaxSeconds = 35
|
||||
previewExpectedMinSeconds = 60
|
||||
largeMismatchMinExpected = 90
|
||||
minAllowedDurationDiff = 15
|
||||
durationDiffRatio = 0.25
|
||||
)
|
||||
|
||||
func ValidateDownloadedTrackDuration(filePath string, expectedSeconds int) (bool, error) {
|
||||
if filePath == "" || expectedSeconds <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
actualDuration, err := GetAudioDuration(filePath)
|
||||
if err != nil || actualDuration <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
actualSeconds := int(math.Round(actualDuration))
|
||||
if actualSeconds <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if expectedSeconds >= previewExpectedMinSeconds && actualSeconds <= previewMaxSeconds {
|
||||
return true, fmt.Errorf("detected preview/sample download: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
|
||||
}
|
||||
|
||||
if expectedSeconds >= largeMismatchMinExpected {
|
||||
allowedDiff := int(math.Max(minAllowedDurationDiff, math.Round(float64(expectedSeconds)*durationDiffRatio)))
|
||||
diff := int(math.Abs(float64(actualSeconds - expectedSeconds)))
|
||||
if diff > allowedDiff {
|
||||
return true, fmt.Errorf("downloaded file duration mismatch: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package backend
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,27 +13,51 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// decodeBase64 decodes a base64 encoded string
|
||||
func decodeBase64(encoded string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func ValidateExecutable(path string) error {
|
||||
cleanedPath := filepath.Clean(path)
|
||||
if cleanedPath == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
return string(decoded), nil
|
||||
|
||||
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=="
|
||||
)
|
||||
|
||||
// GetFFmpegDir returns the directory where ffmpeg should be stored
|
||||
func GetFFmpegDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -42,7 +66,6 @@ func GetFFmpegDir() (string, error) {
|
||||
return filepath.Join(homeDir, ".spotiflac"), nil
|
||||
}
|
||||
|
||||
// GetFFmpegPath returns the full path to the ffmpeg executable
|
||||
func GetFFmpegPath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
@@ -54,10 +77,41 @@ func GetFFmpegPath() (string, error) {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
return filepath.Join(ffmpegDir, ffmpegName), nil
|
||||
localPath := filepath.Join(ffmpegDir, ffmpegName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||
homebrewPath := "/opt/homebrew/bin/" + ffmpegName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
|
||||
homebrewPath := "/usr/local/bin/" + ffmpegName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
path, err := exec.Command("which", ffmpegName).Output()
|
||||
if err == nil {
|
||||
trimmed := strings.TrimSpace(string(path))
|
||||
if trimmed != "" {
|
||||
return trimmed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(ffmpegName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
// GetFFprobePath returns the full path to the ffprobe executable in app directory
|
||||
func GetFFprobePath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
@@ -69,118 +123,191 @@ func GetFFprobePath() (string, error) {
|
||||
ffprobeName = "ffprobe.exe"
|
||||
}
|
||||
|
||||
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
|
||||
if _, err := os.Stat(ffprobePath); err == nil {
|
||||
return ffprobePath, nil
|
||||
localPath := filepath.Join(ffmpegDir, ffprobeName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffprobe not found in app directory")
|
||||
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||
homebrewPath := "/opt/homebrew/bin/" + ffprobeName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
|
||||
homebrewPath := "/usr/local/bin/" + ffprobeName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
path, err := exec.Command("which", ffprobeName).Output()
|
||||
if err == nil {
|
||||
trimmed := strings.TrimSpace(string(path))
|
||||
if trimmed != "" {
|
||||
return trimmed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(ffprobeName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
|
||||
}
|
||||
|
||||
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
|
||||
func IsFFprobeInstalled() (bool, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Verify it's executable
|
||||
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath, "-version")
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
|
||||
func IsFFmpegInstalled() (bool, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = os.Stat(ffmpegPath)
|
||||
if os.IsNotExist(err) {
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Verify it's executable
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
// Hide console window on Windows
|
||||
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
|
||||
func GetBrewPath() string {
|
||||
brewPaths := []string{
|
||||
"/opt/homebrew/bin/brew",
|
||||
"/usr/local/bin/brew",
|
||||
}
|
||||
|
||||
for _, path := range brewPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func IsBrewFFmpegInstalled() (bool, error) {
|
||||
brewPath := GetBrewPath()
|
||||
if brewPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(brewPath, "list", "ffmpeg")
|
||||
setHideWindow(cmd)
|
||||
err := cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
|
||||
brewPath := GetBrewPath()
|
||||
if brewPath == "" {
|
||||
return fmt.Errorf("brew not found")
|
||||
}
|
||||
|
||||
progressCallback(10, "Installing FFmpeg via Homebrew...")
|
||||
|
||||
cmd := exec.Command(brewPath, "install", "ffmpeg")
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to install ffmpeg: %w - %s", err, string(output))
|
||||
}
|
||||
|
||||
progressCallback(100, "done")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip"
|
||||
ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz"
|
||||
)
|
||||
|
||||
func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||
}
|
||||
|
||||
// For macOS, download ffmpeg and ffprobe separately (only if not already installed)
|
||||
if runtime.GOOS == "darwin" {
|
||||
ffmpegInstalled, _ := IsFFmpegInstalled()
|
||||
ffprobeInstalled, _ := IsFFprobeInstalled()
|
||||
|
||||
isARM := runtime.GOARCH == "arm64"
|
||||
|
||||
var macFFmpegURLs []string
|
||||
var macFFprobeURLs []string
|
||||
|
||||
if isARM {
|
||||
|
||||
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"}
|
||||
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"}
|
||||
} else {
|
||||
|
||||
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"}
|
||||
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"}
|
||||
}
|
||||
|
||||
if !ffmpegInstalled && !ffprobeInstalled {
|
||||
// Download both
|
||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
|
||||
if err := downloadWithFallback(macFFmpegURLs, 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)
|
||||
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !ffmpegInstalled {
|
||||
// Only download ffmpeg
|
||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !ffprobeInstalled {
|
||||
// Only download ffprobe
|
||||
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
|
||||
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return fmt.Errorf("failed to download ffprobe: %w", err)
|
||||
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
|
||||
var encodedURL string
|
||||
var url string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
encodedURL = ffmpegWindowsURL
|
||||
url = ffmpegWindowsURL
|
||||
case "linux":
|
||||
encodedURL = ffmpegLinuxURL
|
||||
url = ffmpegLinuxURL
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// Decode URL
|
||||
url, err := decodeBase64(encodedURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
|
||||
|
||||
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -188,9 +315,22 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadAndExtract downloads a file and extracts it
|
||||
func downloadWithFallback(urls []string, destDir string, progressCallback func(int), start, end int) error {
|
||||
var lastErr error
|
||||
for _, url := range urls {
|
||||
fmt.Printf("[FFmpeg] Trying to download from: %s\n", url)
|
||||
err := downloadAndExtract(url, destDir, progressCallback, start, end)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Attempt failed: %v\n", err)
|
||||
}
|
||||
return fmt.Errorf("all download attempts failed: %w", lastErr)
|
||||
}
|
||||
|
||||
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
||||
// Create temporary file for download
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
@@ -198,8 +338,14 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Download the file
|
||||
resp, err := http.Get(url)
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
@@ -211,8 +357,16 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
var downloaded int64
|
||||
lastTime := time.Now()
|
||||
var lastBytes int64
|
||||
|
||||
if totalSize > 0 {
|
||||
totalSizeMB := float64(totalSize) / (1024 * 1024)
|
||||
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
|
||||
}
|
||||
|
||||
// Create a progress reader
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
@@ -222,12 +376,46 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
return fmt.Errorf("failed to write to temp file: %w", writeErr)
|
||||
}
|
||||
downloaded += int64(n)
|
||||
|
||||
mbDownloaded := float64(downloaded) / (1024 * 1024)
|
||||
now := time.Now()
|
||||
timeDiff := now.Sub(lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
|
||||
if timeDiff > 0.1 {
|
||||
bytesDiff := float64(downloaded - lastBytes)
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
lastTime = now
|
||||
lastBytes = downloaded
|
||||
}
|
||||
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
if speedMBps > 0 {
|
||||
SetDownloadSpeed(speedMBps)
|
||||
}
|
||||
|
||||
if totalSize > 0 && progressCallback != nil {
|
||||
// Scale progress between progressStart and progressEnd
|
||||
rawProgress := float64(downloaded) / float64(totalSize)
|
||||
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
|
||||
progressCallback(scaledProgress)
|
||||
}
|
||||
|
||||
if totalSize > 0 {
|
||||
percent := float64(downloaded) * 100 / float64(totalSize)
|
||||
if speedMBps > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
|
||||
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
|
||||
mbDownloaded, float64(totalSize)/(1024*1024), percent)
|
||||
}
|
||||
} else {
|
||||
if speedMBps > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
@@ -239,16 +427,20 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
|
||||
tmpFile.Close()
|
||||
|
||||
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
|
||||
if totalSize > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n",
|
||||
float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024))
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024))
|
||||
}
|
||||
fmt.Printf("[FFmpeg] Extracting...\n")
|
||||
|
||||
// Extract the archive based on file type
|
||||
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
|
||||
return extractTarXz(tmpFile.Name(), destDir)
|
||||
}
|
||||
return extractZip(tmpFile.Name(), destDir)
|
||||
}
|
||||
|
||||
// extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
|
||||
func extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
@@ -280,7 +472,7 @@ func extractZip(zipPath, destDir string) error {
|
||||
destPath = filepath.Join(destDir, ffprobeName)
|
||||
foundFFprobe = true
|
||||
} else {
|
||||
// Skip ffplay and other files
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -308,7 +500,6 @@ func extractZip(zipPath, destDir string) error {
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
// At least one of ffmpeg or ffprobe should be found
|
||||
if !foundFFmpeg && !foundFFprobe {
|
||||
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||
}
|
||||
@@ -323,7 +514,6 @@ func extractZip(zipPath, destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
|
||||
func extractTarXz(tarXzPath, destDir string) error {
|
||||
file, err := os.Open(tarXzPath)
|
||||
if err != nil {
|
||||
@@ -366,7 +556,7 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
destPath = filepath.Join(destDir, ffprobeName)
|
||||
foundFFprobe = true
|
||||
} else {
|
||||
// Skip ffplay and other files
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -387,7 +577,6 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
// At least one of ffmpeg or ffprobe should be found
|
||||
if !foundFFmpeg && !foundFFprobe {
|
||||
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||
}
|
||||
@@ -402,15 +591,13 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"` // mp3, m4a
|
||||
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" (ignored for ALAC)
|
||||
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac"
|
||||
OutputFormat string `json:"output_format"`
|
||||
Bitrate string `json:"bitrate"`
|
||||
Codec string `json:"codec"`
|
||||
}
|
||||
|
||||
// ConvertAudioResult represents the result of a single file conversion
|
||||
type ConvertAudioResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
@@ -418,13 +605,16 @@ type ConvertAudioResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ConvertAudio converts audio files using ffmpeg while preserving metadata
|
||||
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
installed, err := IsFFmpegInstalled()
|
||||
if err != nil || !installed {
|
||||
return nil, fmt.Errorf("ffmpeg is not installed")
|
||||
@@ -434,7 +624,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
// Convert files in parallel
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
@@ -444,16 +633,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
// Get input file info
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
// Determine output directory: same as input file location + subfolder (MP3 or M4A)
|
||||
outputFormatUpper := strings.ToUpper(req.OutputFormat)
|
||||
outputDir := filepath.Join(inputDir, outputFormatUpper)
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||
result.Success = false
|
||||
@@ -463,11 +649,10 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine output path
|
||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||
outputFile = norm.NFC.String(outputFile)
|
||||
|
||||
// Skip if same format
|
||||
if inputExt == outputExt {
|
||||
result.Error = "Input and output formats are the same"
|
||||
result.Success = false
|
||||
@@ -479,11 +664,20 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
|
||||
result.OutputFile = outputFile
|
||||
|
||||
// Extract cover art and lyrics from input file before conversion
|
||||
var coverArtPath string
|
||||
var lyrics string
|
||||
var inputMetadata Metadata
|
||||
|
||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
||||
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
||||
}
|
||||
|
||||
inputFile = norm.NFC.String(inputFile)
|
||||
coverArtPath, err = ExtractCoverArt(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract cover art from %s: %v\n", inputFile, err)
|
||||
}
|
||||
lyrics, err = ExtractLyrics(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||
@@ -493,49 +687,42 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
|
||||
}
|
||||
|
||||
// Build ffmpeg command
|
||||
inputMetadata.Lyrics = lyrics
|
||||
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y", // Overwrite output
|
||||
"-y",
|
||||
}
|
||||
|
||||
// Add codec and bitrate based on output format
|
||||
switch req.OutputFormat {
|
||||
case "mp3":
|
||||
args = append(args,
|
||||
"-codec:a", "libmp3lame",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
"-id3v2_version", "3", // Use ID3v2.3 for better compatibility
|
||||
"-map", "0:a",
|
||||
"-id3v2_version", "3",
|
||||
)
|
||||
// Map video stream if exists (for cover art)
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy")
|
||||
case "m4a":
|
||||
// Determine codec: ALAC (lossless) or AAC (lossy)
|
||||
|
||||
codec := req.Codec
|
||||
if codec == "" {
|
||||
codec = "aac" // Default to AAC for backward compatibility
|
||||
codec = "aac"
|
||||
}
|
||||
|
||||
if codec == "alac" {
|
||||
// ALAC - Apple Lossless (no bitrate needed)
|
||||
|
||||
args = append(args,
|
||||
"-codec:a", "alac",
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
"-map", "0:a",
|
||||
)
|
||||
} else {
|
||||
// AAC - lossy with bitrate
|
||||
|
||||
args = append(args,
|
||||
"-codec:a", "aac",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
"-map", "0:a",
|
||||
)
|
||||
}
|
||||
// Map video stream for cover art in M4A
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
||||
}
|
||||
|
||||
args = append(args, outputFile)
|
||||
@@ -543,7 +730,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
// Hide console window on Windows
|
||||
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -552,21 +739,17 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
// Clean up temp cover art file if exists
|
||||
|
||||
if coverArtPath != "" {
|
||||
os.Remove(coverArtPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Embed cover art and lyrics after conversion if they were extracted
|
||||
if coverArtPath != "" {
|
||||
if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Cover art embedded successfully\n")
|
||||
}
|
||||
os.Remove(coverArtPath) // Clean up temp file
|
||||
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 != "" {
|
||||
@@ -577,6 +760,10 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if coverArtPath != "" {
|
||||
os.Remove(coverArtPath)
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
|
||||
|
||||
@@ -590,7 +777,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetAudioInfo returns information about an audio file
|
||||
type AudioFileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
@@ -598,7 +784,6 @@ type AudioFileInfo struct {
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// GetAudioFileInfo gets information about an audio file
|
||||
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// setHideWindow is a no-op on non-Windows platforms
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
// No-op on Unix-like systems
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setHideWindow sets HideWindow attribute for Windows processes
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// SelectMultipleFiles opens a file dialog to select multiple audio files
|
||||
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Audio Files",
|
||||
@@ -39,7 +38,6 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// SelectOutputDirectory opens a directory dialog to select output folder
|
||||
func SelectOutputDirectory(ctx context.Context) (string, error) {
|
||||
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Output Directory",
|
||||
@@ -49,4 +47,3 @@ func SelectOutputDirectory(ctx context.Context) (string, error) {
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build darwin
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("file path is required")
|
||||
}
|
||||
if imagePath == "" {
|
||||
return fmt.Errorf("image path is required")
|
||||
}
|
||||
|
||||
resizedPath, err := ResizeImageForIcon(imagePath, iconSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(resizedPath)
|
||||
|
||||
script := `
|
||||
use framework "AppKit"
|
||||
on run argv
|
||||
set imagePath to item 1 of argv
|
||||
set targetPath to item 2 of argv
|
||||
set iconImage to current application's NSImage's alloc()'s initWithContentsOfFile:imagePath
|
||||
if iconImage is missing value then error "Failed to load icon image"
|
||||
set didSet to (current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:targetPath options:0) as boolean
|
||||
if didSet is false then error "Failed to set custom file icon"
|
||||
end run
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-", resizedPath, filePath)
|
||||
cmd.Stdin = strings.NewReader(script)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply macOS file icon: %v (%s)", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !darwin
|
||||
|
||||
package backend
|
||||
|
||||
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||
return nil
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/go-flac/go-flac"
|
||||
)
|
||||
|
||||
// FileInfo represents information about a file or folder
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
@@ -23,7 +22,6 @@ type FileInfo struct {
|
||||
Children []FileInfo `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// AudioMetadata represents metadata read from an audio file
|
||||
type AudioMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
@@ -34,7 +32,6 @@ type AudioMetadata struct {
|
||||
Year string `json:"year"`
|
||||
}
|
||||
|
||||
// RenamePreview represents a preview of file rename operation
|
||||
type RenamePreview struct {
|
||||
OldPath string `json:"old_path"`
|
||||
OldName string `json:"old_name"`
|
||||
@@ -44,7 +41,6 @@ type RenamePreview struct {
|
||||
Metadata AudioMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
// RenameResult represents the result of a rename operation
|
||||
type RenameResult struct {
|
||||
OldPath string `json:"old_path"`
|
||||
NewPath string `json:"new_path"`
|
||||
@@ -52,7 +48,6 @@ type RenameResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ListDirectory lists files and folders in a directory
|
||||
func ListDirectory(dirPath string) ([]FileInfo, error) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
@@ -73,7 +68,6 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
|
||||
Size: info.Size(),
|
||||
}
|
||||
|
||||
// If it's a directory, recursively list its contents
|
||||
if entry.IsDir() {
|
||||
children, err := ListDirectory(fileInfo.Path)
|
||||
if err == nil {
|
||||
@@ -87,13 +81,12 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
|
||||
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
||||
var result []FileInfo
|
||||
|
||||
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip files with errors
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
@@ -120,7 +113,6 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadAudioMetadata reads metadata from an audio file
|
||||
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
|
||||
if !fileExists(filePath) {
|
||||
return nil, fmt.Errorf("file does not exist")
|
||||
@@ -140,7 +132,6 @@ func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// readFlacMetadata reads metadata from a FLAC file
|
||||
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -192,7 +183,6 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// readMp3Metadata reads metadata from an MP3 file
|
||||
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
@@ -207,14 +197,12 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
Year: tag.Year(),
|
||||
}
|
||||
|
||||
// Get Album Artist (TPE2)
|
||||
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
metadata.AlbumArtist = textFrame.Text
|
||||
}
|
||||
}
|
||||
|
||||
// Get Track Number
|
||||
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
trackStr := strings.Split(textFrame.Text, "/")[0]
|
||||
@@ -224,7 +212,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get Disc Number
|
||||
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
discStr := strings.Split(textFrame.Text, "/")[0]
|
||||
@@ -237,14 +224,16 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
|
||||
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use ffprobe to get metadata in JSON format (both format and stream tags)
|
||||
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||
return nil, fmt.Errorf("invalid ffprobe executable: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
@@ -253,7 +242,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
filePath,
|
||||
)
|
||||
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.Output()
|
||||
@@ -261,7 +249,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var result struct {
|
||||
Format struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
@@ -277,22 +264,18 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
|
||||
// Merge tags from format and streams (format tags take priority)
|
||||
allTags := make(map[string]string)
|
||||
|
||||
// First add stream tags
|
||||
for _, stream := range result.Streams {
|
||||
for key, value := range stream.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Then add format tags (overwrite stream tags)
|
||||
for key, value := range result.Format.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
for key, value := range allTags {
|
||||
switch key {
|
||||
case "title":
|
||||
@@ -304,7 +287,7 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
case "album_artist", "albumartist":
|
||||
metadata.AlbumArtist = value
|
||||
case "track":
|
||||
// Format might be "4" or "4/12"
|
||||
|
||||
trackStr := strings.Split(value, "/")[0]
|
||||
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||
metadata.TrackNumber = num
|
||||
@@ -324,7 +307,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// readM4aMetadata reads metadata from an M4A file using ffprobe
|
||||
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||
metadata, err := readMetadataWithFFprobe(filePath)
|
||||
if err != nil {
|
||||
@@ -333,7 +315,6 @@ func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// GenerateFilename generates a new filename based on metadata and format template
|
||||
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
@@ -341,32 +322,33 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
||||
|
||||
result := format
|
||||
|
||||
// Replace placeholders
|
||||
year := metadata.Year
|
||||
if len(year) >= 4 {
|
||||
year = year[:4]
|
||||
}
|
||||
|
||||
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
|
||||
result = strings.ReplaceAll(result, "{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(metadata.Year))
|
||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
|
||||
|
||||
// Track number with padding
|
||||
if metadata.TrackNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||
} else {
|
||||
result = strings.ReplaceAll(result, "{track}", "")
|
||||
}
|
||||
|
||||
// Disc number
|
||||
if metadata.DiscNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
|
||||
} else {
|
||||
result = strings.ReplaceAll(result, "{disc}", "")
|
||||
}
|
||||
|
||||
// Clean up multiple spaces and trim
|
||||
result = strings.TrimSpace(result)
|
||||
result = strings.Join(strings.Fields(result), " ")
|
||||
|
||||
// Remove leading/trailing separators
|
||||
result = strings.Trim(result, " -._")
|
||||
|
||||
if result == "" {
|
||||
@@ -376,9 +358,8 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
||||
return result + ext
|
||||
}
|
||||
|
||||
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
|
||||
func sanitizeFilenameForRename(name string) string {
|
||||
// Remove characters that are invalid in filenames
|
||||
|
||||
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
|
||||
result := name
|
||||
for _, char := range invalid {
|
||||
@@ -387,7 +368,6 @@ func sanitizeFilenameForRename(name string) string {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// PreviewRename generates a preview of rename operations
|
||||
func PreviewRename(files []string, format string) []RenamePreview {
|
||||
var previews []RenamePreview
|
||||
|
||||
@@ -424,7 +404,6 @@ func PreviewRename(files []string, format string) []RenamePreview {
|
||||
return previews
|
||||
}
|
||||
|
||||
// GetFileSizes returns file sizes for a list of file paths
|
||||
func GetFileSizes(files []string) map[string]int64 {
|
||||
result := make(map[string]int64)
|
||||
for _, filePath := range files {
|
||||
@@ -436,7 +415,6 @@ func GetFileSizes(files []string) map[string]int64 {
|
||||
return result
|
||||
}
|
||||
|
||||
// RenameFiles renames files based on their metadata
|
||||
func RenameFiles(files []string, format string) []RenameResult {
|
||||
var results []RenameResult
|
||||
|
||||
@@ -466,7 +444,6 @@ func RenameFiles(files []string, format string) []RenameResult {
|
||||
newPath := filepath.Join(filepath.Dir(filePath), newName)
|
||||
result.NewPath = newPath
|
||||
|
||||
// Check if new path already exists (and is different from old path)
|
||||
if newPath != filePath {
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
result.Error = "File already exists"
|
||||
@@ -476,7 +453,6 @@ func RenameFiles(files []string, format string) []RenameResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename the file
|
||||
if err := os.Rename(filePath, newPath); err != nil {
|
||||
result.Error = err.Error()
|
||||
result.Success = false
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -9,41 +11,59 @@ import (
|
||||
"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, "{date}", SanitizeFilename(releaseDate))
|
||||
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)
|
||||
}
|
||||
@@ -52,109 +72,118 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
// sanitizeFilename removes invalid characters from filename
|
||||
func sanitizeFilename(name string) string {
|
||||
// Replace forward slash with space (more natural than underscore)
|
||||
func SanitizeFilename(name string) string {
|
||||
|
||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||
|
||||
// Remove other invalid filesystem characters (replace with space)
|
||||
re := regexp.MustCompile(`[<>:"\\|?*]`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F)
|
||||
var result strings.Builder
|
||||
for _, r := range sanitized {
|
||||
// Keep printable characters and valid Unicode characters
|
||||
// Remove control characters, but keep spaces, tabs, newlines for now
|
||||
|
||||
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
if r == 0x7F {
|
||||
continue
|
||||
}
|
||||
// Remove emoji and other symbols that might cause issues
|
||||
// Keep letters, numbers, and common punctuation
|
||||
|
||||
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
// Remove emoji ranges (most emoji are in these ranges)
|
||||
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
|
||||
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
|
||||
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
|
||||
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
|
||||
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
|
||||
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
|
||||
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
|
||||
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
|
||||
continue
|
||||
}
|
||||
|
||||
result.WriteRune(r)
|
||||
}
|
||||
|
||||
sanitized = result.String()
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
|
||||
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
|
||||
sanitized = strings.Trim(sanitized, ". ")
|
||||
|
||||
// Normalize consecutive spaces to single space
|
||||
re = regexp.MustCompile(`\s+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
// Normalize consecutive underscores to single underscore
|
||||
re = regexp.MustCompile(`_+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, "_")
|
||||
|
||||
// Remove leading/trailing underscores and spaces
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
|
||||
if sanitized == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// Ensure the result is valid UTF-8
|
||||
if !utf8.ValidString(sanitized) {
|
||||
// If invalid UTF-8, try to fix it
|
||||
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// NormalizePath only normalizes path separators without modifying folder names
|
||||
// Use this for user-provided paths that already exist on the filesystem
|
||||
func GetFirstArtist(artistString string) string {
|
||||
if artistString == "" {
|
||||
return ""
|
||||
}
|
||||
delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "}
|
||||
for _, d := range delimiters {
|
||||
if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 {
|
||||
return strings.TrimSpace(artistString[:idx])
|
||||
}
|
||||
}
|
||||
return artistString
|
||||
}
|
||||
|
||||
func NormalizePath(folderPath string) string {
|
||||
// Normalize all forward slashes to backslashes on Windows
|
||||
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
}
|
||||
|
||||
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
|
||||
// Use this only for NEW folders being created (artist names, album names, etc.)
|
||||
func GetSeparator() string {
|
||||
dir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "; "
|
||||
}
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return "; "
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err == nil {
|
||||
if sep, ok := settings["separator"].(string); ok {
|
||||
if sep == "comma" {
|
||||
return ", "
|
||||
}
|
||||
if sep == "semicolon" {
|
||||
return "; "
|
||||
}
|
||||
}
|
||||
}
|
||||
return "; "
|
||||
}
|
||||
|
||||
func SanitizeFolderPath(folderPath string) string {
|
||||
// Normalize all forward slashes to backslashes on Windows
|
||||
|
||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
|
||||
// Detect separator
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
// Split path into components
|
||||
parts := strings.Split(normalizedPath, sep)
|
||||
sanitizedParts := make([]string, 0, len(parts))
|
||||
|
||||
for i, part := range parts {
|
||||
// Keep drive letter intact on Windows (e.g., "C:")
|
||||
|
||||
if i == 0 && len(part) == 2 && part[1] == ':' {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep empty first part for absolute paths on Unix (e.g., "/Users/...")
|
||||
if i == 0 && part == "" {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize each folder name (but don't replace / or \ since we already normalized)
|
||||
sanitized := sanitizeFolderName(part)
|
||||
if sanitized != "" {
|
||||
sanitizedParts = append(sanitizedParts, sanitized)
|
||||
@@ -164,8 +193,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 {
|
||||
// Use the same sanitization as filename
|
||||
return sanitizeFilename(name)
|
||||
func sanitizeFolderName(name string) string { return SanitizeFilename(name) }
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
return SanitizeFilename(name)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -51,12 +50,28 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
||||
|
||||
func SelectFileDialog(ctx context.Context) (string, error) {
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select FLAC File for Analysis",
|
||||
Title: "Select Audio File for Analysis",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{
|
||||
DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)",
|
||||
Pattern: "*.flac;*.mp3;*.m4a;*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "FLAC Audio Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "MP3 Audio Files (*.mp3)",
|
||||
Pattern: "*.mp3",
|
||||
},
|
||||
{
|
||||
DisplayName: "M4A Audio Files (*.m4a)",
|
||||
Pattern: "*.m4a",
|
||||
},
|
||||
{
|
||||
DisplayName: "AAC Audio Files (*.aac)",
|
||||
Pattern: "*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
@@ -69,10 +84,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
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"`
|
||||
Source string `json:"source"`
|
||||
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))
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LRCLibResponse represents the LRCLIB API response
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -27,33 +25,33 @@ type LRCLibResponse struct {
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
// LyricsLine represents a single line of lyrics
|
||||
type LyricsLine struct {
|
||||
StartTimeMs string `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
EndTimeMs string `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
// LyricsResponse represents the API response
|
||||
type LyricsResponse struct {
|
||||
Error bool `json:"error"`
|
||||
SyncType string `json:"syncType"`
|
||||
Lines []LyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
// LyricsDownloadRequest represents a request to download lyrics
|
||||
type LyricsDownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
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"`
|
||||
@@ -62,27 +60,30 @@ type LyricsDownloadResponse struct {
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsClient handles lyrics fetching
|
||||
type LyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewLyricsClient creates a new lyrics client
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) {
|
||||
// Try LRCLIB API
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
|
||||
apiURL := fmt.Sprintf("%s%s&track_name=%s",
|
||||
string(apiBase),
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName, albumName string, duration int) (*LyricsResponse, error) {
|
||||
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
if albumName != "" {
|
||||
apiURL = fmt.Sprintf("%s&album_name=%s", apiURL, url.QueryEscape(albumName))
|
||||
}
|
||||
|
||||
if duration > 0 {
|
||||
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
|
||||
@@ -103,11 +104,13 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*L
|
||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
// Convert LRCLIB response to our LyricsResponse format
|
||||
if lrcLibResp.SyncedLyrics == "" && lrcLibResp.PlainLyrics == "" {
|
||||
return nil, fmt.Errorf("LRCLIB returned empty lyrics")
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||
}
|
||||
|
||||
// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format
|
||||
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
|
||||
resp := &LyricsResponse{
|
||||
Error: false,
|
||||
@@ -115,7 +118,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
Lines: []LyricsLine{},
|
||||
}
|
||||
|
||||
// Prefer synced lyrics, fall back to plain
|
||||
lyricsText := lrcLib.SyncedLyrics
|
||||
if lyricsText == "" {
|
||||
lyricsText = lrcLib.PlainLyrics
|
||||
@@ -127,7 +129,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
return resp
|
||||
}
|
||||
|
||||
// Parse synced lyrics format [mm:ss.xx] text
|
||||
lines := strings.Split(lyricsText, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -135,14 +136,12 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if line has timestamp [mm:ss.xx]
|
||||
if strings.HasPrefix(line, "[") && len(line) > 10 {
|
||||
closeBracket := strings.Index(line, "]")
|
||||
if closeBracket > 0 {
|
||||
timestamp := line[1:closeBracket]
|
||||
words := strings.TrimSpace(line[closeBracket+1:])
|
||||
|
||||
// Convert [mm:ss.xx] to milliseconds
|
||||
ms := lrcTimestampToMs(timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: fmt.Sprintf("%d", ms),
|
||||
@@ -152,9 +151,8 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
}
|
||||
}
|
||||
|
||||
// Plain lyrics line (no timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: "0",
|
||||
StartTimeMs: "",
|
||||
Words: line,
|
||||
})
|
||||
}
|
||||
@@ -162,10 +160,9 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
return resp
|
||||
}
|
||||
|
||||
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
|
||||
func lrcTimestampToMs(timestamp string) int64 {
|
||||
var minutes, seconds, centiseconds int64
|
||||
// Try parsing mm:ss.xx format
|
||||
|
||||
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
||||
if n >= 2 {
|
||||
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
||||
@@ -173,11 +170,11 @@ func lrcTimestampToMs(timestamp string) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
|
||||
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/search?artist_name=%s&track_name=%s",
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
@@ -203,98 +200,157 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
|
||||
return nil, fmt.Errorf("no results found")
|
||||
}
|
||||
|
||||
// Find best match - prefer one with synced lyrics
|
||||
var best *LRCLibResponse
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
for i := range results {
|
||||
if results[i].SyncedLyrics != "" {
|
||||
best = &results[i]
|
||||
break
|
||||
if results[i].SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = &results[i]
|
||||
}
|
||||
if best == nil && results[i].PlainLyrics != "" {
|
||||
best = &results[i]
|
||||
if results[i].PlainLyrics != "" && bestPlain == nil {
|
||||
bestPlain = &results[i]
|
||||
}
|
||||
if bestSynced != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
best := bestSynced
|
||||
if best == nil {
|
||||
best = bestPlain
|
||||
}
|
||||
if best == nil {
|
||||
best = &results[0]
|
||||
}
|
||||
|
||||
if best.SyncedLyrics == "" && best.PlainLyrics == "" {
|
||||
return nil, fmt.Errorf("no lyrics found in search results")
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(best), nil
|
||||
}
|
||||
|
||||
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
|
||||
func simplifyTrackName(name string) string {
|
||||
// Remove content in parentheses
|
||||
|
||||
if idx := strings.Index(name, "("); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
// Remove content after " - " (like "From the Motion Picture")
|
||||
|
||||
if idx := strings.Index(name, " - "); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
|
||||
// 1. Try LRCLIB exact match
|
||||
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB", nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact: %v\n", err)
|
||||
func isSynced(resp *LyricsResponse) bool {
|
||||
return resp != nil && !resp.Error && resp.SyncType == "LINE_SYNCED" && len(resp.Lines) > 0
|
||||
}
|
||||
|
||||
// 2. Try LRCLIB search
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search", nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: %v\n", err)
|
||||
func hasLyrics(resp *LyricsResponse) bool {
|
||||
return resp != nil && !resp.Error && len(resp.Lines) > 0
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName, albumName string, duration int) (*LyricsResponse, string, error) {
|
||||
|
||||
var unsyncedFallback *LyricsResponse
|
||||
var unsyncedSource string
|
||||
|
||||
check := func(resp *LyricsResponse, err error, source string) (*LyricsResponse, string, bool) {
|
||||
if err != nil || resp == nil || resp.Error || len(resp.Lines) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
if isSynced(resp) {
|
||||
return resp, source, true
|
||||
}
|
||||
|
||||
if unsyncedFallback == nil {
|
||||
unsyncedFallback = resp
|
||||
unsyncedSource = source
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
var resp *LyricsResponse
|
||||
var src string
|
||||
var found bool
|
||||
|
||||
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, albumName, duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via exact match (with album)\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact (with album): no synced\n")
|
||||
|
||||
if albumName != "" {
|
||||
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, "", duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB (no album)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via exact match (no album)\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact (no album): no synced\n")
|
||||
}
|
||||
|
||||
resp, _ = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
resp, src, found = check(resp, nil, "LRCLIB Search")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via search\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: no synced\n")
|
||||
|
||||
// 3. Try with simplified track name (remove parentheses, subtitles)
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||
|
||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB (simplified)", nil
|
||||
resp, _ = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, albumName, duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB (simplified)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via simplified exact\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search (simplified)", nil
|
||||
resp, _ = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||
resp, src, found = check(resp, nil, "LRCLIB Search (simplified)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via simplified search\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
}
|
||||
|
||||
if unsyncedFallback != nil {
|
||||
fmt.Printf(" [LRCLIB] No synced found, using unsynced from: %s\n", unsyncedSource)
|
||||
return unsyncedFallback, unsyncedSource + " (unsynced)", 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)
|
||||
@@ -307,40 +363,53 @@ 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)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -349,7 +418,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{
|
||||
@@ -358,7 +467,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}, fmt.Errorf("spotify ID is required")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
@@ -373,15 +481,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,
|
||||
@@ -391,8 +497,17 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch lyrics from LRCLIB
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
audioDuration := 0
|
||||
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
|
||||
if audioFile != "" {
|
||||
duration, err := GetAudioDuration(audioFile)
|
||||
if err == nil && duration > 0 {
|
||||
audioDuration = int(duration)
|
||||
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
|
||||
}
|
||||
}
|
||||
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, audioDuration)
|
||||
if err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
@@ -400,10 +515,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,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var AppVersion = "Unknown"
|
||||
|
||||
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||
|
||||
type MusicBrainzRecordingResponse struct {
|
||||
Recordings []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
Releases []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
} `json:"release-group"`
|
||||
Date string `json:"date"`
|
||||
Country string `json:"country"`
|
||||
Media []struct {
|
||||
Format string `json:"format"`
|
||||
} `json:"media"`
|
||||
LabelInfo []struct {
|
||||
Label struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"label"`
|
||||
} `json:"label-info"`
|
||||
} `json:"releases"`
|
||||
ArtistCredit []struct {
|
||||
Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"artist"`
|
||||
} `json:"artist-credit"`
|
||||
Tags []struct {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) {
|
||||
var meta Metadata
|
||||
|
||||
if !embedGenre {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
if isrc == "" {
|
||||
return meta, fmt.Errorf("no ISRC provided")
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("isrc:%s", isrc)
|
||||
reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query))
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion))
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, lastErr = client.Do(req)
|
||||
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if i < 2 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return meta, lastErr
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var mbResp MusicBrainzRecordingResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
if len(mbResp.Recordings) == 0 {
|
||||
return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc)
|
||||
}
|
||||
|
||||
recording := mbResp.Recordings[0]
|
||||
|
||||
var genres []string
|
||||
caser := cases.Title(language.English)
|
||||
|
||||
if useSingleGenre {
|
||||
|
||||
maxCount := -1
|
||||
var bestTag string
|
||||
|
||||
for _, tag := range recording.Tags {
|
||||
if tag.Count > maxCount {
|
||||
maxCount = tag.Count
|
||||
bestTag = tag.Name
|
||||
}
|
||||
}
|
||||
|
||||
if bestTag != "" {
|
||||
meta.Genre = caser.String(bestTag)
|
||||
}
|
||||
} else {
|
||||
for _, tag := range recording.Tags {
|
||||
|
||||
genres = append(genres, caser.String(tag.Name))
|
||||
}
|
||||
if len(genres) > 0 {
|
||||
|
||||
if len(genres) > 5 {
|
||||
genres = genres[:5]
|
||||
}
|
||||
meta.Genre = strings.Join(genres, GetSeparator())
|
||||
}
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadStatus represents the status of a download item
|
||||
type DownloadStatus string
|
||||
|
||||
const (
|
||||
@@ -18,24 +17,22 @@ 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"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
ISRC string `json:"isrc"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
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,10 +184,7 @@ 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) {
|
||||
func AddToQueue(id, trackName, artistName, albumName, spotifyID string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
@@ -212,7 +193,7 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
||||
TrackName: trackName,
|
||||
ArtistName: artistName,
|
||||
AlbumName: albumName,
|
||||
ISRC: isrc,
|
||||
SpotifyID: spotifyID,
|
||||
Status: StatusQueued,
|
||||
Progress: 0,
|
||||
TotalSize: 0,
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -77,10 +77,9 @@ 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)
|
||||
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
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,7 +92,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
}
|
||||
|
||||
var searchResp QobuzSearchResponse
|
||||
// Read body first to handle encoding issues
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
@@ -104,7 +103,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
@@ -119,93 +118,138 @@ 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 := quality // Use the provided quality parameter
|
||||
if qualityCode == "" {
|
||||
qualityCode = "6" // Default to FLAC 16-bit if not specified
|
||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||
if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") {
|
||||
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||
}
|
||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||
}
|
||||
|
||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
|
||||
|
||||
// Decode base64 API URLs
|
||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||
|
||||
// Try primary API first
|
||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
resp, err = q.client.Get(fallbackURL)
|
||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||
resp, err := q.client.Get(apiURL)
|
||||
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("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
return "", fmt.Errorf("empty body")
|
||||
}
|
||||
|
||||
fmt.Printf("Fallback API response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
return streamResp.URL, nil
|
||||
}
|
||||
|
||||
var nestedResp struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
||||
return nestedResp.Data.URL, nil
|
||||
}
|
||||
|
||||
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("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://qbz.afkarxyz.qzz.io/api/track/",
|
||||
}
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
type Provider struct {
|
||||
Name string
|
||||
Func func() (string, error)
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if streamResp.URL == "" {
|
||||
return "", fmt.Errorf("no download URL available")
|
||||
url, err := downloadFunc(qualityCode)
|
||||
if err == nil {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Got download URL from fallback API\n")
|
||||
return streamResp.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...")
|
||||
// Use a separate client with a longer timeout. The default client's 60s limit
|
||||
// causes downloads to fail on slow connections or for large Hi-Res files.
|
||||
|
||||
downloadClient := &http.Client{
|
||||
Timeout: 5 * time.Minute, // 5 minutes for large files
|
||||
Timeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
resp, err := downloadClient.Get(url)
|
||||
@@ -226,14 +270,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
|
||||
}
|
||||
@@ -263,42 +306,53 @@ 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)
|
||||
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -307,22 +361,52 @@ 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, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
func (q *QobuzDownloader) DownloadTrack(spotifyID, 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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
var deezerISRC string
|
||||
if spotifyID != "" {
|
||||
songlinkClient := NewSongLinkClient()
|
||||
isrc, err := songlinkClient.GetISRCDirect(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get ISRC: %v", err)
|
||||
}
|
||||
deezerISRC = isrc
|
||||
} else {
|
||||
return "", fmt.Errorf("spotify ID is required for Qobuz download")
|
||||
}
|
||||
|
||||
return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, 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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||
|
||||
metaChan := make(chan Metadata, 1)
|
||||
if embedGenre && deezerISRC != "" {
|
||||
go func() {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
metaChan <- fetchedMeta
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
metaChan <- Metadata{}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
track, err := q.SearchByISRC(isrc)
|
||||
track, err := q.searchByISRC(deezerISRC)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// All metadata from Spotify - no fallback to Qobuz
|
||||
artists := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
@@ -337,7 +421,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)
|
||||
}
|
||||
@@ -346,7 +430,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] + "..."
|
||||
@@ -354,16 +437,17 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
fmt.Printf("Download URL obtained: %s\n", urlPreview)
|
||||
|
||||
safeArtist := sanitizeFilename(artists)
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Check if file with same ISRC already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", isrc, existingFile)
|
||||
return "EXISTS:" + existingFile, nil
|
||||
if useFirstArtistOnly {
|
||||
safeArtist = sanitizeFilename(GetFirstArtist(artists))
|
||||
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
// Build filename based on format settings (use Spotify track number)
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, spotifyTrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
safeAlbum := sanitizeFilename(albumTitle)
|
||||
|
||||
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 {
|
||||
@@ -379,7 +463,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
fmt.Printf("Downloaded: %s\n", filepath)
|
||||
|
||||
coverPath := ""
|
||||
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
|
||||
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = filepath + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
@@ -392,26 +476,34 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
|
||||
// Determine track number to embed - ALL from Spotify
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if position > 0 && !useAlbumTrackNumber {
|
||||
trackNumberToEmbed = position // Use playlist position
|
||||
var mbMeta Metadata
|
||||
if deezerISRC != "" {
|
||||
mbMeta = <-metaChan
|
||||
}
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
// ALL metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artists,
|
||||
Album: albumTitle,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: isrc, // ISRC from Spotify (passed as parameter)
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
ISRC: deezerISRC,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type FlacInfo struct {
|
||||
Path string `json:"path"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
}
|
||||
|
||||
func GetFlacInfoBatch(paths []string) []FlacInfo {
|
||||
results := make([]FlacInfo, len(paths))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, path := range paths {
|
||||
wg.Add(1)
|
||||
go func(idx int, p string) {
|
||||
defer wg.Done()
|
||||
info := FlacInfo{Path: p}
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
results[idx] = info
|
||||
return
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
p,
|
||||
}
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
results[idx] = info
|
||||
return
|
||||
}
|
||||
|
||||
kvMap := make(map[string]string)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
|
||||
kvMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := kvMap["sample_rate"]; ok {
|
||||
if s, err := strconv.Atoi(v); err == nil {
|
||||
info.SampleRate = uint32(s)
|
||||
}
|
||||
}
|
||||
|
||||
bits := 0
|
||||
if v, ok := kvMap["bits_per_raw_sample"]; ok && v != "N/A" && v != "" {
|
||||
bits, _ = strconv.Atoi(v)
|
||||
}
|
||||
if bits == 0 {
|
||||
if v, ok := kvMap["bits_per_sample"]; ok && v != "N/A" && v != "" {
|
||||
bits, _ = strconv.Atoi(v)
|
||||
}
|
||||
}
|
||||
info.BitsPerSample = uint8(bits)
|
||||
|
||||
results[idx] = info
|
||||
}(i, path)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
type ResampleRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
}
|
||||
|
||||
type ResampleResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func buildFolderLabel(sampleRate, bitDepth string) string {
|
||||
var parts []string
|
||||
|
||||
if bitDepth != "" {
|
||||
parts = append(parts, bitDepth+"bit")
|
||||
}
|
||||
|
||||
switch sampleRate {
|
||||
case "44100":
|
||||
parts = append(parts, "44.1kHz")
|
||||
case "48000":
|
||||
parts = append(parts, "48kHz")
|
||||
case "96000":
|
||||
parts = append(parts, "96kHz")
|
||||
case "192000":
|
||||
parts = append(parts, "192kHz")
|
||||
default:
|
||||
if sampleRate != "" {
|
||||
parts = append(parts, sampleRate+"Hz")
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "Resampled"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func ResampleAudio(req ResampleRequest) ([]ResampleResult, 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")
|
||||
}
|
||||
|
||||
if req.SampleRate == "" && req.BitDepth == "" {
|
||||
return nil, fmt.Errorf("at least one of sample rate or bit depth must be specified")
|
||||
}
|
||||
|
||||
results := make([]ResampleResult, len(req.InputFiles))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
folderLabel := buildFolderLabel(req.SampleRate, req.BitDepth)
|
||||
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
defer wg.Done()
|
||||
|
||||
result := ResampleResult{
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
outputDir := filepath.Join(inputDir, folderLabel)
|
||||
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
|
||||
}
|
||||
|
||||
outputFile := filepath.Join(outputDir, baseName+".flac")
|
||||
result.OutputFile = outputFile
|
||||
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y",
|
||||
}
|
||||
|
||||
if req.BitDepth != "" {
|
||||
switch req.BitDepth {
|
||||
case "16":
|
||||
args = append(args, "-c:a", "flac", "-sample_fmt", "s16")
|
||||
case "24":
|
||||
args = append(args, "-c:a", "flac", "-sample_fmt", "s32", "-bits_per_raw_sample", "24")
|
||||
default:
|
||||
args = append(args, "-c:a", "flac")
|
||||
}
|
||||
} else {
|
||||
args = append(args, "-c:a", "flac")
|
||||
}
|
||||
|
||||
if req.SampleRate != "" {
|
||||
args = append(args, "-ar", req.SampleRate)
|
||||
}
|
||||
|
||||
args = append(args, "-map_metadata", "0")
|
||||
args = append(args, outputFile)
|
||||
|
||||
fmt.Printf("[Resample] %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("resampling failed: %s - %s", err.Error(), string(output))
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
fmt.Printf("[Resample] Done: %s\n", outputFile)
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
}(i, inputFile)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/cmplx"
|
||||
|
||||
"github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// SpectrumData contains frequency spectrum information
|
||||
type SpectrumData struct {
|
||||
TimeSlices []TimeSlice `json:"time_slices"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
FreqBins int `json:"freq_bins"`
|
||||
Duration float64 `json:"duration"`
|
||||
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)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
info := stream.Info
|
||||
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)
|
||||
}
|
||||
|
||||
if len(samples) == 0 {
|
||||
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
|
||||
|
||||
// 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])
|
||||
}
|
||||
sample /= float64(channels)
|
||||
|
||||
allSamples = append(allSamples, sample)
|
||||
|
||||
// Limit sample count
|
||||
if len(allSamples) >= maxSamples {
|
||||
return allSamples, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allSamples, nil
|
||||
}
|
||||
|
||||
// calculateSpectrum performs FFT analysis on audio samples
|
||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
||||
fftSize := 8192
|
||||
numTimeSlices := 300
|
||||
|
||||
duration := float64(len(samples)) / float64(sampleRate)
|
||||
|
||||
samplesPerSlice := len(samples) / numTimeSlices
|
||||
if samplesPerSlice < fftSize {
|
||||
samplesPerSlice = fftSize
|
||||
numTimeSlices = len(samples) / fftSize
|
||||
}
|
||||
|
||||
timeSlices := make([]TimeSlice, 0, numTimeSlices)
|
||||
freqBins := fftSize / 2
|
||||
maxFreq := float64(sampleRate) / 2.0
|
||||
|
||||
for i := 0; i < numTimeSlices; i++ {
|
||||
startIdx := i * samplesPerSlice
|
||||
if startIdx+fftSize > len(samples) {
|
||||
break
|
||||
}
|
||||
|
||||
window := samples[startIdx : startIdx+fftSize]
|
||||
|
||||
windowedSamples := applyHannWindow(window)
|
||||
|
||||
spectrum := fft(windowedSamples)
|
||||
|
||||
magnitudes := make([]float64, freqBins)
|
||||
for j := 0; j < freqBins; j++ {
|
||||
magnitude := cmplx.Abs(spectrum[j])
|
||||
|
||||
if magnitude < 1e-10 {
|
||||
magnitude = 1e-10
|
||||
}
|
||||
magnitudes[j] = 20 * math.Log10(magnitude)
|
||||
}
|
||||
|
||||
timeSlice := TimeSlice{
|
||||
Time: float64(startIdx) / float64(sampleRate),
|
||||
Magnitudes: magnitudes,
|
||||
}
|
||||
timeSlices = append(timeSlices, timeSlice)
|
||||
}
|
||||
|
||||
return &SpectrumData{
|
||||
TimeSlices: timeSlices,
|
||||
SampleRate: sampleRate,
|
||||
FreqBins: freqBins,
|
||||
Duration: duration,
|
||||
MaxFreq: maxFreq,
|
||||
}
|
||||
}
|
||||
|
||||
// applyHannWindow applies Hann window to reduce spectral leakage
|
||||
func applyHannWindow(samples []float64) []float64 {
|
||||
n := len(samples)
|
||||
windowed := make([]float64, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
|
||||
windowed[i] = samples[i] * window
|
||||
}
|
||||
|
||||
return windowed
|
||||
}
|
||||
|
||||
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
|
||||
func fft(samples []float64) []complex128 {
|
||||
n := len(samples)
|
||||
|
||||
x := make([]complex128, n)
|
||||
for i := 0; i < n; i++ {
|
||||
x[i] = complex(samples[i], 0)
|
||||
}
|
||||
|
||||
return fftRecursive(x)
|
||||
}
|
||||
|
||||
// fftRecursive performs recursive FFT
|
||||
func fftRecursive(x []complex128) []complex128 {
|
||||
n := len(x)
|
||||
|
||||
if n <= 1 {
|
||||
return x
|
||||
}
|
||||
|
||||
even := make([]complex128, n/2)
|
||||
odd := make([]complex128, n/2)
|
||||
|
||||
for i := 0; i < n/2; i++ {
|
||||
even[i] = x[2*i]
|
||||
odd[i] = x[2*i+1]
|
||||
}
|
||||
|
||||
evenFFT := fftRecursive(even)
|
||||
oddFFT := fftRecursive(odd)
|
||||
|
||||
result := make([]complex128, n)
|
||||
for k := 0; k < n/2; k++ {
|
||||
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
|
||||
result[k] = evenFFT[k] + t
|
||||
result[k+n/2] = evenFFT[k] - t
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error {
|
||||
if callback == nil || len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
const chunkSize = 25
|
||||
for start := 0; start < len(tracks); start += chunkSize {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
end := start + chunkSize
|
||||
if end > len(tracks) {
|
||||
end = len(tracks)
|
||||
}
|
||||
|
||||
callback(tracks[start:end])
|
||||
|
||||
if end < len(tracks) {
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) {
|
||||
if !useAPI || apiBaseURL == "" {
|
||||
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback)
|
||||
}
|
||||
|
||||
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
||||
if spotifyType == "" || id == "" {
|
||||
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
|
||||
}
|
||||
|
||||
if spotifyType == "artist" {
|
||||
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create API request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read API response: %w", err)
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
|
||||
switch spotifyType {
|
||||
case "track":
|
||||
var trackResp TrackResponse
|
||||
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||
}
|
||||
data = trackResp
|
||||
case "album":
|
||||
var albumResp AlbumResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||
}
|
||||
data = &albumResp
|
||||
if callback != nil {
|
||||
callback(&AlbumResponsePayload{
|
||||
AlbumInfo: albumResp.AlbumInfo,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
if err := streamTrackListChunks(ctx, albumResp.TrackList, callback); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case "playlist":
|
||||
var playlistResp PlaylistResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||
}
|
||||
data = playlistResp
|
||||
if callback != nil {
|
||||
callback(PlaylistResponsePayload{
|
||||
PlaylistInfo: playlistResp.PlaylistInfo,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
if err := streamTrackListChunks(ctx, playlistResp.TrackList, callback); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case "artist":
|
||||
var artistResp ArtistDiscographyPayload
|
||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||
}
|
||||
data = &artistResp
|
||||
if callback != nil {
|
||||
callback(&ArtistDiscographyPayload{
|
||||
ArtistInfo: artistResp.ArtistInfo,
|
||||
AlbumList: artistResp.AlbumList,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
if err := streamTrackListChunks(ctx, artistResp.TrackList, callback); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
switch payload := data.(type) {
|
||||
case TrackResponse:
|
||||
t := payload.Track
|
||||
callback([]AlbumTrackMetadata{{
|
||||
SpotifyID: t.SpotifyID,
|
||||
Artists: t.Artists,
|
||||
Name: t.Name,
|
||||
AlbumName: t.AlbumName,
|
||||
AlbumArtist: t.AlbumArtist,
|
||||
DurationMS: t.DurationMS,
|
||||
Images: t.Images,
|
||||
ReleaseDate: t.ReleaseDate,
|
||||
TrackNumber: t.TrackNumber,
|
||||
TotalTracks: t.TotalTracks,
|
||||
DiscNumber: t.DiscNumber,
|
||||
TotalDiscs: t.TotalDiscs,
|
||||
ExternalURL: t.ExternalURL,
|
||||
Plays: t.Plays,
|
||||
PreviewURL: t.PreviewURL,
|
||||
IsExplicit: t.IsExplicit,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func parseSpotifyURLToTypeAndID(url string) (string, string) {
|
||||
|
||||
if strings.HasPrefix(url, "spotify:") {
|
||||
parts := strings.Split(url, ":")
|
||||
if len(parts) >= 3 {
|
||||
return parts[1], parts[2]
|
||||
}
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`spotify\.com/(track|album|playlist|artist)/([a-zA-Z0-9]+)`)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) == 3 {
|
||||
return matches[1], matches[2]
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||
<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>
|
||||
@@ -16,39 +16,43 @@
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.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.18",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.12.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.34.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.3.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.50.0",
|
||||
"vite": "^7.3.0"
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
c94dda3302d3338d7909ef5d634d0fde
|
||||
867c45db7982e126a7249d80210f23be
|
||||
@@ -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();
|
||||
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -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"
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 903 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
|
||||
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1_219)">
|
||||
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
|
||||
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
|
||||
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
|
||||
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
|
||||
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
|
||||
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,387 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||
import XIcon from "@/assets/x.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 KofiLogo from "@/assets/ko-fi.gif";
|
||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||
import { langColors } from "@/assets/github-lang-colors";
|
||||
export function AboutPage() {
|
||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||
useEffect(() => {
|
||||
const fetchRepoStats = async () => {
|
||||
const CACHE_KEY = "github_repo_stats_v3";
|
||||
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) {
|
||||
const repoData = await repoRes.json();
|
||||
const releases = releasesRes.ok ? await releasesRes.json() : [];
|
||||
const languages = langsRes.ok ? await langsRes.json() : {};
|
||||
let totalDownloads = 0;
|
||||
let latestDownloads = 0;
|
||||
let latestVersion = "";
|
||||
if (releases.length > 0) {
|
||||
latestVersion = releases[0].tag_name || "";
|
||||
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,
|
||||
description: repoData.description,
|
||||
totalDownloads,
|
||||
latestDownloads,
|
||||
latestVersion,
|
||||
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 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 getRepoDescription = (repoName: string): string => {
|
||||
return repoStats[repoName]?.description || "";
|
||||
};
|
||||
return (<div className="flex flex-col space-y-4">
|
||||
<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 === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
||||
<Blocks className="h-4 w-4"/>
|
||||
Other Projects
|
||||
</Button>
|
||||
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
||||
<Heart className="h-4 w-4"/>
|
||||
Support Me
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
|
||||
{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.qzz.io/")}>
|
||||
<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={XIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X"/>
|
||||
<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>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
|
||||
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiDownloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiDownloader
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("SpotiDownloader")}
|
||||
</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="gap-2 hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiFLAC Next
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("SpotiFLAC-Next")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
|
||||
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<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="rounded-md border border-sky-500/25 bg-sky-500/8 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-sky-700 dark:text-sky-300">
|
||||
<Info className="h-3.5 w-3.5"/>
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
||||
SpotiFLAC Next is a separate project created as a thank-you
|
||||
to everyone who has supported SpotiFLAC on Ko-fi.
|
||||
</p>
|
||||
</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>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
Twitter/X Media Batch Downloader
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("Twitter-X-Media-Batch-Downloader")}
|
||||
</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>)}
|
||||
|
||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
||||
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-32 flex items-center justify-center w-full relative">
|
||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Enjoying the project? You can support ongoing development by buying me a coffee.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
||||
Support me on Ko-fi
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4 w-full">
|
||||
<div className="h-32 flex items-center justify-center">
|
||||
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
||||
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Crypto donations are also accepted. Scan the QR code or copy the address.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
||||
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||
setCopiedUsdt(true);
|
||||
setTimeout(() => setCopiedUsdt(false), 500);
|
||||
}}>
|
||||
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,273 +1,224 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText, 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 { getSettings } from "@/lib/settings";
|
||||
import { downloadCover } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
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;
|
||||
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) => 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;
|
||||
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;
|
||||
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: (id: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (id: 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,
|
||||
isBulkDownloadingLyrics,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllLyrics,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onPageChange,
|
||||
onArtistClick,
|
||||
onTrackClick,
|
||||
}: AlbumInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
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) {
|
||||
const settings = getSettings();
|
||||
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
|
||||
const handleDownloadAlbumCover = async () => {
|
||||
if (!albumInfo.images)
|
||||
return;
|
||||
setDownloadingAlbumCover(true);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const albumName = albumInfo.name;
|
||||
const artistName = albumInfo.artists;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
album_artist: artistName?.replace(/\//g, placeholder),
|
||||
title: albumName?.replace(/\//g, placeholder),
|
||||
year: albumInfo.release_date?.substring(0, 4),
|
||||
date: albumInfo.release_date,
|
||||
};
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadCover({
|
||||
cover_url: albumInfo.images,
|
||||
track_name: albumName,
|
||||
artist_name: "",
|
||||
album_name: "",
|
||||
album_artist: "",
|
||||
release_date: "",
|
||||
output_dir: outputDir,
|
||||
filename_format: "title",
|
||||
track_number: false,
|
||||
position: 0,
|
||||
disc_number: 0,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists)
|
||||
toast.info("Cover already exists");
|
||||
else
|
||||
toast.success("Separate album cover downloaded");
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
}
|
||||
finally {
|
||||
setDownloadingAlbumCover(false);
|
||||
}
|
||||
};
|
||||
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 && (<div className="relative group shrink-0 w-48 h-48">
|
||||
<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadAlbumCover} disabled={downloadingAlbumCover}>
|
||||
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Download Separate Album Cover</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>)}
|
||||
<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} {albumInfo.total_tracks === 1 ? "song" : "songs"}
|
||||
{albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
variant="secondary"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText 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>
|
||||
</Tooltip>)}
|
||||
{onDownloadAllCovers && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
<p>Download All Separate 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>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
interface ApiSource {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
const SOURCES: ApiSource[] = [
|
||||
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
|
||||
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
|
||||
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
||||
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
||||
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
||||
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
||||
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
||||
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" },
|
||||
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" },
|
||||
];
|
||||
export function ApiStatusTab() {
|
||||
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
|
||||
const [isCheckingAll, setIsCheckingAll] = useState(false);
|
||||
const checkStatus = async (sourceId: string, apiType: string, url: string) => {
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: "checking" }));
|
||||
try {
|
||||
const isOnline = await CheckAPIStatus(apiType, url);
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" }));
|
||||
}
|
||||
catch (error) {
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: "offline" }));
|
||||
}
|
||||
};
|
||||
const checkAll = async () => {
|
||||
setIsCheckingAll(true);
|
||||
const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url));
|
||||
await Promise.allSettled(promises);
|
||||
setIsCheckingAll(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
checkAll();
|
||||
}, []);
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" onClick={checkAll} disabled={isCheckingAll} className="gap-2">
|
||||
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
||||
Refresh All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{SOURCES.map((source) => {
|
||||
const status = statuses[source.id] || "idle";
|
||||
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{status === "checking" && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground"/>}
|
||||
{status === "online" && <CheckCircle2 className="h-5 w-5 text-emerald-500"/>}
|
||||
{status === "offline" && <XCircle className="h-5 w-5 text-destructive"/>}
|
||||
{status === "idle" && <div className="h-5 w-5 rounded-full bg-muted"/>}
|
||||
</div>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,315 +1,624 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck, 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;
|
||||
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) => 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;
|
||||
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;
|
||||
artistInfo: {
|
||||
name: string;
|
||||
images: string;
|
||||
header?: string;
|
||||
gallery?: string[];
|
||||
followers: number;
|
||||
total_albums?: 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: (id: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (id: 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,
|
||||
isBulkDownloadingLyrics,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllLyrics,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onAlbumClick,
|
||||
onArtistClick,
|
||||
onPageChange,
|
||||
onTrackClick,
|
||||
}: ArtistInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{albumList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-2xl font-bold">Discography</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{albumList.map((album) => (
|
||||
<div
|
||||
key={album.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
onAlbumClick({
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
external_urls: album.external_urls,
|
||||
})
|
||||
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 [activeAlbumFilter, setActiveAlbumFilter] = useState<string>("all");
|
||||
const displayedAlbumCount = artistInfo.total_albums || albumList.length;
|
||||
const albumFilterCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
counts.set("all", (albumList || []).length);
|
||||
for (const album of albumList || []) {
|
||||
const type = (album.album_type || "").trim().toLowerCase();
|
||||
if (!type)
|
||||
continue;
|
||||
counts.set(type, (counts.get(type) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [albumList]);
|
||||
const albumFilters = useMemo(() => {
|
||||
const uniqueTypes = Array.from(new Set((albumList || [])
|
||||
.map((album) => (album.album_type || "").trim().toLowerCase())
|
||||
.filter(Boolean)));
|
||||
return ["all", ...uniqueTypes];
|
||||
}, [albumList]);
|
||||
const filteredAlbums = useMemo(() => {
|
||||
if (activeAlbumFilter === "all") {
|
||||
return albumList || [];
|
||||
}
|
||||
return (albumList || []).filter((album) => (album.album_type || "").trim().toLowerCase() === activeAlbumFilter);
|
||||
}, [albumList, activeAlbumFilter]);
|
||||
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 formatAlbumFilterLabel = (value: string) => {
|
||||
const count = albumFilterCounts.get(value) || 0;
|
||||
if (value === "all")
|
||||
return `All (${count})`;
|
||||
const label = value
|
||||
.split(/[_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
return `${label} (${count})`;
|
||||
};
|
||||
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");
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
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={onDownloadAllLyrics}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
|
||||
<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 line-clamp-4">{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>{displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"}</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||
{artistInfo.genres.length > 0 && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>) : (<CardContent className="px-6 py-6">
|
||||
{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 line-clamp-4">{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>{displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"}</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||
{artistInfo.genres.length > 0 && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
|
||||
<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.toLocaleString()})</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
||||
{downloadingAllGallery ? <Spinner className="h-4 w-4"/> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Gallery</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{artistInfo.gallery!.map((imageUrl, index) => (<div key={index} className="relative group">
|
||||
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
|
||||
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => handleDownloadGalleryImage(imageUrl, index)} size="sm" variant="secondary" disabled={downloadingGalleryIndex === index} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||
{downloadingGalleryIndex === index ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Image {index + 1}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-2xl font-bold">Discography</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Discography
|
||||
</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>)}
|
||||
</div>
|
||||
</div>
|
||||
{albumFilters.length > 1 && (<div className="flex flex-wrap gap-2">
|
||||
{albumFilters.map((filter) => (<Button key={filter} size="sm" variant={activeAlbumFilter === filter ? "default" : "outline"} onClick={() => setActiveAlbumFilter(filter)}>
|
||||
{formatAlbumFilterLabel(filter)}
|
||||
</Button>))}
|
||||
</div>)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filteredAlbums.map((album) => {
|
||||
const albumTracks = trackList.filter(t => t.album_name === album.name);
|
||||
const tracksWithId = albumTracks.filter(t => t.spotify_id);
|
||||
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
||||
const hasTracks = tracksWithId.length > 0;
|
||||
return (<div key={album.id} className="group cursor-pointer relative" onClick={() => onAlbumClick({
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
external_urls: album.external_urls,
|
||||
})}>
|
||||
<div className="relative mb-2">
|
||||
|
||||
{hasTracks && (<div className={`absolute top-2 left-2 z-20 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`} onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => onToggleSelectAll(albumTracks)} className="bg-black/50 border-white/70 data-[state=checked]:bg-primary data-[state=checked]:border-primary"/>
|
||||
</div>)}
|
||||
{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 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>
|
||||
{filteredAlbums.length === 0 && (<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
No releases found for the selected discography filter.
|
||||
</div>)}
|
||||
</div>)}
|
||||
|
||||
{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">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 tracksWithId = data.tracks.filter(t => t.spotify_id);
|
||||
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
||||
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"/>)}
|
||||
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.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onDownloadAllLyrics} size="sm" variant="outline" disabled={isBulkDownloadingLyrics}>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Lyrics</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{onDownloadAllCovers && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
<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>
|
||||
<p>Download All Separate 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>);
|
||||
}
|
||||
|
||||
@@ -1,161 +1,179 @@
|
||||
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 } from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
|
||||
interface AudioAnalysisProps {
|
||||
result: AnalysisResult | null;
|
||||
analyzing: boolean;
|
||||
onAnalyze?: () => void;
|
||||
showAnalyzeButton?: boolean;
|
||||
filePath?: string;
|
||||
result: AnalysisResult | null;
|
||||
analyzing: boolean;
|
||||
onAnalyze?: () => void;
|
||||
showAnalyzeButton?: boolean;
|
||||
filePath?: string;
|
||||
}
|
||||
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>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Activity className="h-12 w-12 text-primary"/>
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files
|
||||
</p>
|
||||
</div>
|
||||
{onAnalyze && (<Button onClick={onAnalyze}>
|
||||
<Activity className="h-4 w-4"/>
|
||||
Analyze Audio
|
||||
</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);
|
||||
};
|
||||
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;
|
||||
const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A";
|
||||
const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:";
|
||||
const hasCodecMeta = result.file_type === "MP3" && (Boolean(result.codec_mode) ||
|
||||
typeof result.bitrate_kbps === "number" ||
|
||||
typeof result.total_frames === "number" ||
|
||||
Boolean(result.codec_version));
|
||||
return (<Card className="gap-2">
|
||||
<CardHeader className="pb-2">
|
||||
{filePath && (<p className="text-sm font-mono break-all text-muted-foreground">{filePath}</p>)}
|
||||
</CardHeader>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
<CardContent>
|
||||
<div className={`grid grid-cols-1 gap-6 md:grid-cols-2 ${hasCodecMeta ? "lg:grid-cols-4" : "lg:grid-cols-3"}`}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{result.file_type && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<span className="font-medium font-mono">{result.file_type}</span>
|
||||
</li>)}
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sample Rate:</span>
|
||||
<span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Bit Depth:</span>
|
||||
<span className="font-medium font-mono">{result.bit_depth}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Channels:</span>
|
||||
<span className="font-medium font-mono">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-medium font-mono">{formatDuration(result.duration)}</span>
|
||||
</li>
|
||||
{result.file_size > 0 && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Size:</span>
|
||||
<span className="font-medium font-mono">{formatFileSize(result.file_size)}</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
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-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" />
|
||||
Analyze Audio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Signal Analytics</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Nyquist:</span>
|
||||
<span className="font-medium font-mono">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||
<span className="font-medium font-mono">{formatNumber(result.dynamic_range)} dB</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Peak Amplitude:</span>
|
||||
<span className="font-medium font-mono">{formatNumber(result.peak_amplitude)} dB</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">RMS Level:</span>
|
||||
<span className="font-medium font-mono">{formatNumber(result.rms_level)} dB</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Samples:</span>
|
||||
<span className="font-medium font-mono">{totalSamplesText}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
{hasCodecMeta && (<div className="space-y-2">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">MP3 Meta</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{result.codec_mode && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Mode:</span>
|
||||
<span className="font-medium font-mono">{result.codec_mode}</span>
|
||||
</li>)}
|
||||
{typeof result.bitrate_kbps === "number" && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Bitrate:</span>
|
||||
<span className="font-medium font-mono">{result.bitrate_kbps} kbps</span>
|
||||
</li>)}
|
||||
{typeof result.total_frames === "number" && result.total_frames > 0 && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Frames:</span>
|
||||
<span className="font-medium font-mono">{result.total_frames.toLocaleString()}</span>
|
||||
</li>)}
|
||||
{result.codec_version && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<span className="font-medium font-mono">{result.codec_version}</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div>)}
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
// Calculate Nyquist frequency (half of sample rate)
|
||||
const nyquistFreq = result.sample_rate / 2;
|
||||
|
||||
return (
|
||||
<Card className="gap-2">
|
||||
<CardHeader>
|
||||
{filePath && (
|
||||
<p className="text-sm font-mono break-all">{filePath}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
{/* Audio Properties - Single line */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Sample Rate:</span>
|
||||
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||
</div>
|
||||
<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="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="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="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 - Single line */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
{result.spectrum && (() => {
|
||||
const frames = result.spectrum.time_slices.length;
|
||||
const fftSize = (result.spectrum.freq_bins - 1) * 2;
|
||||
const freqRes = result.sample_rate / fftSize;
|
||||
return (<div className="space-y-2">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Display Frames:</span>
|
||||
<span className="font-medium font-mono">{frames.toLocaleString()}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">FFT Size:</span>
|
||||
<span className="font-medium font-mono">{fftSize.toLocaleString()}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">{freqResolutionLabel}</span>
|
||||
<span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>);
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
}
|
||||
|
||||
@@ -1,155 +1,226 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Upload, ArrowLeft, Trash2, Download } 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 { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
|
||||
interface AudioAnalysisPageProps {
|
||||
onBack?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"];
|
||||
const SUPPORTED_AUDIO_ACCEPT = [
|
||||
".flac",
|
||||
".mp3",
|
||||
".m4a",
|
||||
".aac",
|
||||
"audio/flac",
|
||||
"audio/x-flac",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
"audio/aac",
|
||||
"audio/aacp",
|
||||
].join(",");
|
||||
const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC";
|
||||
function isSupportedAudioPath(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase();
|
||||
return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
||||
}
|
||||
function isSupportedAudioFile(file: File): boolean {
|
||||
const normalizedName = file.name.toLowerCase();
|
||||
const normalizedType = file.type.toLowerCase();
|
||||
return (SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) ||
|
||||
normalizedType === "audio/flac" ||
|
||||
normalizedType === "audio/x-flac" ||
|
||||
normalizedType === "audio/mpeg" ||
|
||||
normalizedType === "audio/mp3" ||
|
||||
normalizedType === "audio/mp4" ||
|
||||
normalizedType === "audio/x-m4a" ||
|
||||
normalizedType === "audio/aac" ||
|
||||
normalizedType === "audio/aacp");
|
||||
}
|
||||
function isAbsolutePath(filePath: string): boolean {
|
||||
return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath);
|
||||
}
|
||||
function fileNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || filePath;
|
||||
}
|
||||
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
await analyzeFile(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
|
||||
await analyzeFile(filePath);
|
||||
},
|
||||
[analyzeFile]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const spectrumRef = useRef<{
|
||||
getCanvasDataURL: () => string | null;
|
||||
}>(null);
|
||||
const analyzeSelectedPath = useCallback(async (filePath: string) => {
|
||||
if (!isSupportedAudioPath(filePath)) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await analyzeFilePath(filePath);
|
||||
}, [analyzeFilePath]);
|
||||
const analyzeSelectedFile = useCallback(async (file: File) => {
|
||||
if (!isSupportedAudioFile(file)) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await analyzeFile(file);
|
||||
}, [analyzeFile]);
|
||||
const handleSelectFile = useCallback(async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
await analyzeSelectedPath(filePath);
|
||||
}
|
||||
catch {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, [analyzeSelectedPath]);
|
||||
const handleInputChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file)
|
||||
return;
|
||||
await analyzeSelectedFile(file);
|
||||
e.target.value = "";
|
||||
}, [analyzeSelectedFile]);
|
||||
const handleHtmlDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file)
|
||||
return;
|
||||
await analyzeSelectedFile(file);
|
||||
}, [analyzeSelectedFile]);
|
||||
useEffect(() => {
|
||||
OnFileDrop((_x, _y, paths) => {
|
||||
setIsDragging(false);
|
||||
const droppedPath = paths?.[0];
|
||||
if (!droppedPath)
|
||||
return;
|
||||
void analyzeSelectedPath(droppedPath);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [analyzeSelectedPath]);
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!spectrumRef.current)
|
||||
return;
|
||||
const dataUrl = spectrumRef.current.getCanvasDataURL();
|
||||
if (!dataUrl) {
|
||||
toast.error("Export Failed", { description: "Cannot get canvas data" });
|
||||
return;
|
||||
}
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (selectedFilePath && isAbsolutePath(selectedFilePath)) {
|
||||
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
|
||||
toast.success("Exported Successfully", {
|
||||
description: `Saved to: ${outPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const base = selectedFilePath
|
||||
? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "")
|
||||
: "spectrogram";
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = `${base}_spectrogram.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
toast.success("Exported Successfully", {
|
||||
description: "Spectrogram image downloaded",
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Export Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to export image",
|
||||
});
|
||||
}
|
||||
finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [selectedFilePath]);
|
||||
const handleAnalyzeAnother = () => {
|
||||
clearResult();
|
||||
};
|
||||
}, [handleFileDrop]);
|
||||
const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined;
|
||||
return (<div className="space-y-6">
|
||||
<input ref={fileInputRef} type="file" accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
|
||||
|
||||
const handleAnalyzeAnother = () => {
|
||||
clearResult();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<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 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"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<Upload className="h-5 w-5" />
|
||||
Select FLAC File
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Analysis Results */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* Detailed Analysis */}
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath} />
|
||||
|
||||
{/* Spectrum Visualization */}
|
||||
{spectrumLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
||||
<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 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 && (<div className="flex gap-2">
|
||||
<Button onClick={handleExport} variant="outline" size="sm" disabled={isExporting || spectrumLoading}>
|
||||
<Download className="h-4 w-4 mr-1"/>
|
||||
{isExporting ? "Exporting..." : "Export PNG"}
|
||||
</Button>
|
||||
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4 mr-1"/>
|
||||
Clear
|
||||
</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
) : (
|
||||
<SpectrumVisualization
|
||||
sampleRate={result.sample_rate}
|
||||
bitsPerSample={result.bits_per_sample}
|
||||
duration={result.duration}
|
||||
spectrumData={result.spectrum}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
{!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={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as 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 audio file here"
|
||||
: "Drag and drop an audio file here, or click the button below to select"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Audio File
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3, M4A, AAC
|
||||
</p>
|
||||
</div>)}
|
||||
|
||||
{analyzing && !result && (<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>Processing...</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{result && (<div className="space-y-4">
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
||||
|
||||
<SpectrumVisualization ref={spectrumRef} sampleRate={result.sample_rate} duration={result.duration} spectrumData={result.spectrum} fileName={fileName} onReAnalyze={reAnalyzeSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||
</div>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
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 } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
import { AudioLinesIcon } from "@/components/ui/audio-lines";
|
||||
interface AudioFile {
|
||||
path: string;
|
||||
name: string;
|
||||
format: string;
|
||||
size: number;
|
||||
status: "pending" | "resampling" | "success" | "error";
|
||||
error?: string;
|
||||
outputPath?: string;
|
||||
srcSampleRate?: number;
|
||||
srcBitDepth?: number;
|
||||
}
|
||||
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];
|
||||
}
|
||||
function formatSampleRate(sr: number): string {
|
||||
if (!sr)
|
||||
return "";
|
||||
if (sr === 44100)
|
||||
return "44.1kHz";
|
||||
if (sr >= 1000)
|
||||
return `${sr / 1000}kHz`;
|
||||
return `${sr}Hz`;
|
||||
}
|
||||
const SAMPLE_RATE_OPTIONS = [
|
||||
{ value: "44100", label: "44.1kHz" },
|
||||
{ value: "48000", label: "48kHz" },
|
||||
{ value: "96000", label: "96kHz" },
|
||||
{ value: "192000", label: "192kHz" },
|
||||
];
|
||||
const BIT_DEPTH_OPTIONS = [
|
||||
{ value: "16", label: "16-bit" },
|
||||
{ value: "24", label: "24-bit" },
|
||||
];
|
||||
const STORAGE_KEY = "spotiflac_audio_resampler_state";
|
||||
export function AudioResamplerPage() {
|
||||
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 [sampleRate, setSampleRate] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.sampleRate)
|
||||
return parsed.sampleRate;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
return "44100";
|
||||
});
|
||||
const [bitDepth, setBitDepth] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.bitDepth)
|
||||
return parsed.bitDepth;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
return "16";
|
||||
});
|
||||
const [resampling, setResampling] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const saveState = useCallback((stateToSave: {
|
||||
files: AudioFile[];
|
||||
sampleRate: string;
|
||||
bitDepth: string;
|
||||
}) => {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to save state:", err);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
saveState({ files, sampleRate, bitDepth });
|
||||
}, [files, sampleRate, bitDepth, saveState]);
|
||||
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 fetchAudioInfo = useCallback(async (paths: string[]) => {
|
||||
if (paths.length === 0)
|
||||
return;
|
||||
try {
|
||||
const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"];
|
||||
const infos: Array<{
|
||||
path: string;
|
||||
sample_rate: number;
|
||||
bits_per_sample: number;
|
||||
}> = await GetFlacInfoBatch(paths);
|
||||
setFiles((prev) => prev.map((f) => {
|
||||
const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase());
|
||||
if (info) {
|
||||
return {
|
||||
...f,
|
||||
srcSampleRate: info.sample_rate || undefined,
|
||||
srcBitDepth: info.bits_per_sample || undefined,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to fetch audio info:", err);
|
||||
}
|
||||
}, []);
|
||||
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 handleSelectFolder = async () => {
|
||||
try {
|
||||
const selectedFolder = await SelectFolder("");
|
||||
if (selectedFolder) {
|
||||
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||
if (folderFiles && folderFiles.length > 0) {
|
||||
addFiles(folderFiles.map((f) => f.path));
|
||||
}
|
||||
else {
|
||||
toast.info("No audio files found", {
|
||||
description: "No FLAC files found in the selected folder.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Folder Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||
});
|
||||
}
|
||||
};
|
||||
const addFiles = useCallback(async (paths: string[]) => {
|
||||
const validExtensions = [".flac"];
|
||||
const invalidFiles = paths.filter((path) => {
|
||||
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||
return !validExtensions.includes(ext);
|
||||
});
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error("Unsupported format", {
|
||||
description: "Only FLAC files are supported for resampling.",
|
||||
});
|
||||
}
|
||||
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) : {};
|
||||
let newlyAddedPaths: string[] = [];
|
||||
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,
|
||||
};
|
||||
});
|
||||
newlyAddedPaths = newFiles.map((f) => f.path);
|
||||
if (newFiles.length > 0) {
|
||||
if (paths.length > newFiles.length + invalidFiles.length) {
|
||||
const skipped = paths.length - newFiles.length - invalidFiles.length;
|
||||
toast.info("Some files skipped", {
|
||||
description: `${skipped} file(s) were already added`,
|
||||
});
|
||||
}
|
||||
return [...prev, ...newFiles];
|
||||
}
|
||||
if (validPaths.length > 0 && newFiles.length === 0) {
|
||||
toast.info("No new files added", {
|
||||
description: "All valid files were already added",
|
||||
});
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (newlyAddedPaths.length > 0) {
|
||||
fetchAudioInfo(newlyAddedPaths);
|
||||
}
|
||||
}, 50);
|
||||
}, [fetchAudioInfo]);
|
||||
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 handleResample = async () => {
|
||||
if (files.length === 0) {
|
||||
toast.error("No files selected", {
|
||||
description: "Please add FLAC files to resample",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setResampling(true);
|
||||
try {
|
||||
const inputPaths = files.map((f) => f.path);
|
||||
setFiles((prev) => prev.map((f) => {
|
||||
if (inputPaths.includes(f.path)) {
|
||||
return { ...f, status: "resampling" as const, error: undefined };
|
||||
}
|
||||
return f;
|
||||
}));
|
||||
const results = await ResampleAudio({
|
||||
input_files: inputPaths,
|
||||
sample_rate: sampleRate,
|
||||
bit_depth: bitDepth,
|
||||
});
|
||||
setFiles((prev) => prev.map((f) => {
|
||||
const result = results.find((r: any) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
|
||||
if (result) {
|
||||
return {
|
||||
...f,
|
||||
status: result.success ? "success" : "error",
|
||||
error: result.error,
|
||||
outputPath: result.output_file,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
}));
|
||||
const successCount = results.filter((r: any) => r.success).length;
|
||||
const failCount = results.filter((r: any) => !r.success).length;
|
||||
if (successCount > 0) {
|
||||
toast.success("Resampling Complete", {
|
||||
description: `Successfully resampled ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||
});
|
||||
}
|
||||
else if (failCount > 0) {
|
||||
toast.error("Resampling Failed", {
|
||||
description: `All ${failCount} file(s) failed to resample`,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Resampling Error", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Resampling failed" })));
|
||||
}
|
||||
finally {
|
||||
setResampling(false);
|
||||
}
|
||||
};
|
||||
const getStatusIcon = (status: AudioFile["status"]) => {
|
||||
switch (status) {
|
||||
case "resampling":
|
||||
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 resampleableCount = 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 Resampler</h1>
|
||||
{files.length > 0 && (<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Files
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Folder
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={clearFiles} disabled={resampling}>
|
||||
<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>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Folder
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported format: FLAC
|
||||
</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 flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Bit Depth:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={bitDepth} onValueChange={(value) => {
|
||||
if (value)
|
||||
setBitDepth(value);
|
||||
}}>
|
||||
{BIT_DEPTH_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Sample Rate:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={sampleRate} onValueChange={(value) => {
|
||||
if (value)
|
||||
setSampleRate(value);
|
||||
}}>
|
||||
{SAMPLE_RATE_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} resampled
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
||||
{files.map((file) => {
|
||||
const srcParts: string[] = [];
|
||||
if (file.srcBitDepth)
|
||||
srcParts.push(`${file.srcBitDepth}-bit`);
|
||||
if (file.srcSampleRate)
|
||||
srcParts.push(formatSampleRate(file.srcSampleRate));
|
||||
const srcSpec = srcParts.join(" / ");
|
||||
return (<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>
|
||||
|
||||
{srcSpec ? (<span className="text-xs font-medium text-primary bg-primary/10 rounded px-1.5 py-0.5 whitespace-nowrap shrink-0">
|
||||
{srcSpec}
|
||||
</span>) : file.status === "pending" ? (<span className="text-xs text-muted-foreground/50 whitespace-nowrap shrink-0">
|
||||
reading...
|
||||
</span>) : null}
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span className="text-xs uppercase text-muted-foreground shrink-0">
|
||||
{file.format}
|
||||
</span>
|
||||
{file.status !== "resampling" && (<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeFile(file.path)} disabled={resampling}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>)}
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||
<Button onClick={handleResample} disabled={resampling || resampleableCount === 0} size="lg">
|
||||
{resampling ? (<>
|
||||
<Spinner className="h-4 w-4"/>
|
||||
Resampling...
|
||||
</>) : (<>
|
||||
<AudioLinesIcon size={16} className="text-primary-foreground"/>
|
||||
Resample{" "}
|
||||
{resampleableCount > 0 ? `${resampleableCount} File(s)` : ""}
|
||||
</>)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,101 +1,105 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Trash2, Copy, Check } from "lucide-react";
|
||||
import { Trash2, Copy, Check, FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logger, type LogEntry } from "@/lib/logger";
|
||||
|
||||
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
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);
|
||||
const queueInfo = useDownloadQueueData();
|
||||
const hasDownloadActivity = queueInfo.queue.length > 0 ||
|
||||
queueInfo.queued_count > 0 ||
|
||||
queueInfo.completed_count > 0 ||
|
||||
queueInfo.failed_count > 0 ||
|
||||
queueInfo.skipped_count > 0;
|
||||
const canExportFailed = hasDownloadActivity && queueInfo.failed_count > 0;
|
||||
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);
|
||||
}
|
||||
};
|
||||
const handleExportFailed = async () => {
|
||||
if (!canExportFailed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const message = await ExportFailedDownloads();
|
||||
if (message.startsWith("Successfully")) {
|
||||
toast.success(message);
|
||||
}
|
||||
else if (message !== "Export cancelled") {
|
||||
toast.info(message);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to export:", error);
|
||||
toast.error(`Failed to export: ${error}`);
|
||||
}
|
||||
};
|
||||
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={handleExportFailed} disabled={!canExportFailed}>
|
||||
<FileDown className="h-4 w-4"/>
|
||||
Export Failed
|
||||
</Button>
|
||||
<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 +107,7 @@ export function DebugLoggerPage() {
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="break-all">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>)))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,30 +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) {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
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={clampedProgress} className="h-2 flex-1" />
|
||||
<Progress value={clampedProgress} className="h-2 flex-1"/>
|
||||
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
|
||||
<StopCircle className="h-4 w-4" />
|
||||
<StopCircle className="h-4 w-4"/>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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="h-auto cursor-pointer rounded-lg border-border bg-background p-3 text-foreground shadow-lg transition-colors hover:bg-muted dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100 dark:hover:bg-blue-900" 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-blue-600 dark:text-blue-400 ${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 font-mono tabular-nums text-muted-foreground dark:text-blue-300">
|
||||
{progress.speed_mbps.toFixed(2)} MB/s
|
||||
</p>
|
||||
)}
|
||||
</p>)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
|
||||
<ChevronRight className="ml-1 h-4 w-4 text-muted-foreground dark:text-blue-300"/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,287 +1,274 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
|
||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer, FileDown } 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, ExportFailedDownloads } 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">
|
||||
<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 handleExportFailed = async () => {
|
||||
try {
|
||||
const message = await ExportFailedDownloads();
|
||||
if (message.startsWith("Successfully")) {
|
||||
toast.success(message);
|
||||
}
|
||||
else if (message !== "Export cancelled") {
|
||||
toast.info(message);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to export:", error);
|
||||
toast.error(`Failed to export: ${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",
|
||||
};
|
||||
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`;
|
||||
}
|
||||
};
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const toggleFilter = (status: string) => {
|
||||
setFilterStatus(prev => prev === status ? "all" : status);
|
||||
};
|
||||
const filteredQueue = queueInfo.queue.filter((item: any) => {
|
||||
if (filterStatus === "all")
|
||||
return true;
|
||||
return item.status === filterStatus;
|
||||
});
|
||||
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>)}
|
||||
{queueInfo.failed_count > 0 && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleExportFailed}>
|
||||
<FileDown className="h-3 w-3"/>
|
||||
Export Failures
|
||||
</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 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'queued' ? 'bg-secondary px-2 py-0.5 rounded-md ring-1 ring-border' : ''}`} onClick={() => toggleFilter('queued')}>
|
||||
<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 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'completed' ? 'bg-green-500/10 px-2 py-0.5 rounded-md ring-1 ring-green-500/20' : ''}`} onClick={() => toggleFilter('completed')}>
|
||||
<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 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'skipped' ? 'bg-yellow-500/10 px-2 py-0.5 rounded-md ring-1 ring-yellow-500/20' : ''}`} onClick={() => toggleFilter('skipped')}>
|
||||
<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 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'failed' ? 'bg-red-500/10 px-2 py-0.5 rounded-md ring-1 ring-red-500/20' : ''}`} onClick={() => toggleFilter('failed')}>
|
||||
<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>) : filteredQueue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No downloads with status "{filterStatus}"</p>
|
||||
<Button variant="link" onClick={() => setFilterStatus("all")}>Clear filter</Button>
|
||||
</div>) : (filteredQueue.map((item: any) => (<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>);
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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, Qobuz & Amazon Music — no account required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
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";
|
||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
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;
|
||||
source: 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 getTrackLink = (spotifyId: string) => {
|
||||
if (spotifyId?.startsWith("tidal_"))
|
||||
return { url: `https://listen.tidal.com/track/${spotifyId.replace("tidal_", "")}`, label: "Open in Tidal" };
|
||||
if (spotifyId?.startsWith("qobuz_"))
|
||||
return { url: `https://www.qobuz.com/track/${spotifyId.replace("qobuz_", "")}`, label: "Open in Qobuz" };
|
||||
if (spotifyId?.startsWith("amazon_"))
|
||||
return { url: `https://music.amazon.com/tracks/${spotifyId.replace("amazon_", "")}`, label: "Open in Amazon Music" };
|
||||
if (spotifyId?.startsWith("deezer_"))
|
||||
return { url: `https://www.deezer.com/track/${spotifyId.replace("deezer_", "")}`, label: "Open in Deezer" };
|
||||
return { url: `https://open.spotify.com/track/${spotifyId}`, label: "Open in Spotify" };
|
||||
};
|
||||
const getSourceIcon = (source: string) => {
|
||||
const s = source?.toLowerCase() || "";
|
||||
if (s.includes("tidal"))
|
||||
return <TidalIcon className="h-4 w-4 object-contain rounded"/>;
|
||||
if (s.includes("qobuz"))
|
||||
return <QobuzIcon className="h-4 w-4 object-contain"/>;
|
||||
if (s.includes("amazon"))
|
||||
return <AmazonIcon className="h-4 w-4 object-contain rounded"/>;
|
||||
if (s.includes("deezer"))
|
||||
return <Music2 className="h-4 w-4"/>;
|
||||
if (s.includes("spotify"))
|
||||
return <Music2 className="h-4 w-4"/>;
|
||||
return <Music2 className="h-4 w-4 opacity-50"/>;
|
||||
};
|
||||
const fetchDownloadHistory = async () => {
|
||||
try {
|
||||
const items = await GetDownloadHistory();
|
||||
setDownloadHistory((items || []) as unknown as DownloadHistoryItem[]);
|
||||
}
|
||||
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);
|
||||
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
|
||||
useEffect(() => {
|
||||
setDownloadCurrentPage(1);
|
||||
}, [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);
|
||||
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
|
||||
useEffect(() => {
|
||||
setFetchCurrentPage(1);
|
||||
}, [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 = SPOTIFY_PREVIEW_VOLUME;
|
||||
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>
|
||||
{filteredDownloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
||||
{filteredDownloadHistory.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 w-[35%]">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-48 lg:w-48 xl:w-56">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-16 text-xs uppercase text-nowrap">Source</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', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
||||
</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 text-left">
|
||||
<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">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center">
|
||||
{getSourceIcon(item.source)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="capitalize">{item.source || "Unknown"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{!(item.spotify_id?.startsWith('tidal_') || item.spotify_id?.startsWith('qobuz_') || item.spotify_id?.startsWith('amazon_') || item.spotify_id?.startsWith('deezer_')) && (<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(getTrackLink(item.spotify_id).url)}>
|
||||
<ExternalLink className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getTrackLink(item.spotify_id).label}</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>);
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
// Platform Icons for streaming services
|
||||
|
||||
export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
export const TidalIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
</svg>);
|
||||
export const QobuzIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AmazonIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
</svg>);
|
||||
export const AmazonIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
);
|
||||
</svg>);
|
||||
|
||||
@@ -1,266 +1,234 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText, 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 { getSettings } from "@/lib/settings";
|
||||
import { downloadCover } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
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;
|
||||
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) => 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;
|
||||
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;
|
||||
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: (id: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (id: 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,
|
||||
isBulkDownloadingLyrics,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllLyrics,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onPageChange,
|
||||
onAlbumClick,
|
||||
onArtistClick,
|
||||
onTrackClick,
|
||||
}: PlaylistInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
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) {
|
||||
const settings = getSettings();
|
||||
const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false);
|
||||
const handleDownloadPlaylistCover = async () => {
|
||||
if (!playlistInfo.cover)
|
||||
return;
|
||||
setDownloadingPlaylistCover(true);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const playlistName = playlistInfo.owner.name;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: "",
|
||||
album: "",
|
||||
album_artist: "",
|
||||
title: playlistName.replace(/\//g, placeholder),
|
||||
playlist: playlistName.replace(/\//g, placeholder),
|
||||
};
|
||||
if (settings.createPlaylistFolder && playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadCover({
|
||||
cover_url: playlistInfo.cover,
|
||||
track_name: playlistName,
|
||||
artist_name: "",
|
||||
album_name: "",
|
||||
album_artist: "",
|
||||
release_date: "",
|
||||
output_dir: outputDir,
|
||||
filename_format: "title",
|
||||
track_number: false,
|
||||
position: 0,
|
||||
disc_number: 0,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists)
|
||||
toast.info("Cover already exists");
|
||||
else
|
||||
toast.success("Separate playlist cover downloaded");
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
}
|
||||
finally {
|
||||
setDownloadingPlaylistCover(false);
|
||||
}
|
||||
};
|
||||
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 && (<div className="relative group shrink-0 w-48 h-48">
|
||||
<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadPlaylistCover} disabled={downloadingPlaylistCover}>
|
||||
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Download Separate Playlist Cover</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>)}
|
||||
<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} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
|
||||
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||
<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>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText 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>
|
||||
</Tooltip>)}
|
||||
{onDownloadAllCovers && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
<p>Download All Separate 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>);
|
||||
}
|
||||
|
||||
@@ -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,12 @@ 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>
|
||||
<SelectItem value="failed">Failed Downloads</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,803 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CloudDownload, Info, XCircle } from "lucide-react";
|
||||
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, ArrowUpDown, } 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";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
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 [resultFilter, setResultFilter] = useState("");
|
||||
const [sortOrders, setSortOrders] = useState<Record<ResultTab, string>>({
|
||||
tracks: "default",
|
||||
albums: "default",
|
||||
artists: "default",
|
||||
playlists: "default",
|
||||
});
|
||||
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 [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false);
|
||||
const [invalidUrl, setInvalidUrl] = useState("");
|
||||
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);
|
||||
setResultFilter("");
|
||||
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 isSpotifyUrl = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed)
|
||||
return true;
|
||||
const isUrl = /^(https?:\/\/|www\.)/i.test(trimmed) || /^spotify:/i.test(trimmed);
|
||||
if (!isUrl)
|
||||
return true;
|
||||
return (trimmed.includes("spotify.com") ||
|
||||
trimmed.includes("spotify.link") ||
|
||||
trimmed.startsWith("spotify:"));
|
||||
};
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
if (searchMode)
|
||||
return;
|
||||
const pastedText = e.clipboardData.getData("text");
|
||||
if (pastedText && !isSpotifyUrl(pastedText)) {
|
||||
e.preventDefault();
|
||||
setInvalidUrl(pastedText);
|
||||
setShowInvalidUrlDialog(true);
|
||||
}
|
||||
};
|
||||
const handleFetchWithValidation = () => {
|
||||
if (!isSpotifyUrl(url)) {
|
||||
setInvalidUrl(url);
|
||||
setShowInvalidUrlDialog(true);
|
||||
return;
|
||||
}
|
||||
onFetch();
|
||||
};
|
||||
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 sortedResults = useMemo(() => {
|
||||
if (!searchResults)
|
||||
return { tracks: [], albums: [], artists: [], playlists: [] };
|
||||
const filterStr = resultFilter.toLowerCase();
|
||||
let tracks = [...searchResults.tracks];
|
||||
if (filterStr) {
|
||||
tracks = tracks.filter(t => (t.name || '').toLowerCase().includes(filterStr) || (t.artists || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const tSort = sortOrders.tracks;
|
||||
if (tSort !== 'default') {
|
||||
tracks.sort((a, b) => {
|
||||
if (tSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (tSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (tSort === 'artist-asc')
|
||||
return (a.artists || '').localeCompare(b.artists || '');
|
||||
if (tSort === 'artist-desc')
|
||||
return (b.artists || '').localeCompare(a.artists || '');
|
||||
if (tSort === 'duration-desc')
|
||||
return (b.duration_ms || 0) - (a.duration_ms || 0);
|
||||
if (tSort === 'duration-asc')
|
||||
return (a.duration_ms || 0) - (b.duration_ms || 0);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let albums = [...searchResults.albums];
|
||||
if (filterStr) {
|
||||
albums = albums.filter(a => (a.name || '').toLowerCase().includes(filterStr) || (a.artists || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const alSort = sortOrders.albums;
|
||||
if (alSort !== 'default') {
|
||||
albums.sort((a, b) => {
|
||||
if (alSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (alSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (alSort === 'artist-asc')
|
||||
return (a.artists || '').localeCompare(b.artists || '');
|
||||
if (alSort === 'artist-desc')
|
||||
return (b.artists || '').localeCompare(a.artists || '');
|
||||
if (alSort === 'year-desc')
|
||||
return (b.release_date || '').localeCompare(a.release_date || '');
|
||||
if (alSort === 'year-asc')
|
||||
return (a.release_date || '').localeCompare(b.release_date || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let artists = [...searchResults.artists];
|
||||
if (filterStr) {
|
||||
artists = artists.filter(a => (a.name || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const arSort = sortOrders.artists;
|
||||
if (arSort !== 'default') {
|
||||
artists.sort((a, b) => {
|
||||
if (arSort === 'name-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (arSort === 'name-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let playlists = [...searchResults.playlists];
|
||||
if (filterStr) {
|
||||
playlists = playlists.filter(p => (p.name || '').toLowerCase().includes(filterStr) || (p.owner || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const pSort = sortOrders.playlists;
|
||||
if (pSort !== 'default') {
|
||||
playlists.sort((a, b) => {
|
||||
if (pSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (pSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (pSort === 'owner-asc')
|
||||
return (a.owner || '').localeCompare(b.owner || '');
|
||||
if (pSort === 'owner-desc')
|
||||
return (b.owner || '').localeCompare(a.owner || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return { tracks, albums, artists, playlists };
|
||||
}, [searchResults, sortOrders, resultFilter]);
|
||||
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>
|
||||
<p className="mt-1">Note: Playlist must be public (not private)</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...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudDownload className="h-4 w-4" />
|
||||
Fetch
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
{!searchMode ? (<>
|
||||
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} 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("");
|
||||
setResultFilter("");
|
||||
}}>
|
||||
<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={handleFetchWithValidation} disabled={loading}>
|
||||
{loading ? (<>
|
||||
<Spinner />
|
||||
Fetching...
|
||||
</>) : (<>
|
||||
<CloudDownload className="h-4 w-4"/>
|
||||
Fetch
|
||||
</>)}
|
||||
</Button>
|
||||
</>)}
|
||||
</div>
|
||||
{!hasResult && (
|
||||
<FetchHistory
|
||||
history={history}
|
||||
onSelect={onHistorySelect}
|
||||
onRemove={onHistoryRemove}
|
||||
/>
|
||||
)}
|
||||
</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 mb-4">
|
||||
{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="flex gap-2 mb-4">
|
||||
<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 ${activeTab}...`} value={resultFilter} onChange={(e) => setResultFilter(e.target.value)} className="pl-10 pr-8"/>
|
||||
{resultFilter && (<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={() => setResultFilter("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</div>
|
||||
<Select value={sortOrders[activeTab]} onValueChange={(val) => setSortOrders(prev => ({ ...prev, [activeTab]: val }))}>
|
||||
<SelectTrigger className="w-[170px] bg-background gap-1.5">
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground"/>
|
||||
<SelectValue placeholder="Sort by"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
{activeTab === 'tracks' && (<>
|
||||
<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-desc">Duration (Longest)</SelectItem>
|
||||
<SelectItem value="duration-asc">Duration (Shortest)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'albums' && (<>
|
||||
<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="year-desc">Year (Newest)</SelectItem>
|
||||
<SelectItem value="year-asc">Year (Oldest)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'artists' && (<>
|
||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'playlists' && (<>
|
||||
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||
<SelectItem value="owner-asc">Owner (A-Z)</SelectItem>
|
||||
<SelectItem value="owner-desc">Owner (Z-A)</SelectItem>
|
||||
</>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{activeTab === "tracks" &&
|
||||
sortedResults.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" &&
|
||||
sortedResults.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" &&
|
||||
sortedResults.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" &&
|
||||
sortedResults.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>)}
|
||||
|
||||
<Dialog open={showInvalidUrlDialog} onOpenChange={setShowInvalidUrlDialog}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invalid URL</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only Spotify links are allowed in Fetch mode.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{invalidUrl && (<div className="p-3 bg-muted rounded-md border text-xs font-mono break-all opacity-70">
|
||||
{invalidUrl}
|
||||
</div>)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowInvalidUrlDialog(false);
|
||||
setInvalidUrl("");
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
onSearchModeChange(true);
|
||||
setShowInvalidUrlDialog(false);
|
||||
setInvalidUrl("");
|
||||
}}>
|
||||
Switch to Search
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,181 +1,200 @@
|
||||
import { FileMusic, FilePen } from "lucide-react";
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
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 { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity";
|
||||
import { TerminalIcon } from "@/components/ui/terminal";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks";
|
||||
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
|
||||
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
||||
interface SidebarProps {
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
}
|
||||
interface AnimatedIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
{/* Home */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "main" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("main")}
|
||||
>
|
||||
<HomeIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Home</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false);
|
||||
const [hasIssueAgreement, setHasIssueAgreement] = useState(false);
|
||||
const analyzerIconRef = useRef<ActivityIconHandle>(null);
|
||||
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
|
||||
const converterIconRef = useRef<FileMusicIconHandle>(null);
|
||||
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
|
||||
const handleIssuesDialogChange = (open: boolean) => {
|
||||
setIsIssuesDialogOpen(open);
|
||||
if (!open) {
|
||||
setHasIssueAgreement(false);
|
||||
}
|
||||
};
|
||||
const handleOpenIssues = () => {
|
||||
openExternal("https://github.com/afkarxyz/SpotiFLAC/issues");
|
||||
handleIssuesDialogChange(false);
|
||||
};
|
||||
const getAnimatedItemHandlers = <T extends AnimatedIconHandle>(iconRef: RefObject<T | null>) => ({
|
||||
onMouseEnter: () => iconRef.current?.startAnimation(),
|
||||
onMouseLeave: () => iconRef.current?.stopAnimation(),
|
||||
onFocus: () => iconRef.current?.startAnimation(),
|
||||
onBlur: () => iconRef.current?.stopAnimation(),
|
||||
});
|
||||
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">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<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>Home</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "settings" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("settings")}
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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>
|
||||
|
||||
{/* Audio Analysis */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "audio-analysis" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("audio-analysis")}
|
||||
>
|
||||
<ActivityIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Quality Analyzer</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>
|
||||
|
||||
{/* Audio Converter - using lucide icon (no animated version) */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "audio-converter" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("audio-converter")}
|
||||
>
|
||||
<FileMusic className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Converter</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>
|
||||
|
||||
{/* File Manager - using lucide icon (no animated version) */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "file-manager" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("file-manager")}
|
||||
>
|
||||
<FilePen className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={0}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||
<BlocksIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Tools</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="right" sideOffset={14} className="min-w-[200px] ml-2">
|
||||
<DropdownMenuItem onClick={() => onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(analyzerIconRef)}>
|
||||
<ActivityIcon ref={analyzerIconRef} size={16}/>
|
||||
<span>Audio Quality Analyzer</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("audio-resampler")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(resamplerIconRef)}>
|
||||
<AudioLinesIcon ref={resamplerIconRef} size={16}/>
|
||||
<span>Audio Resampler</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(converterIconRef)}>
|
||||
<FileMusicIcon ref={converterIconRef} size={16}/>
|
||||
<span>Audio Converter</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(fileManagerIconRef)}>
|
||||
<FilePenIcon ref={fileManagerIconRef} size={16}/>
|
||||
<span>File Manager</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Debug */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "debug" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("debug")}
|
||||
>
|
||||
<TerminalIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Debug Logs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Bottom icons */}
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||
>
|
||||
<GithubIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bug</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://exyezed.cc/")}
|
||||
>
|
||||
<BlocksIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Other Projects</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
|
||||
>
|
||||
<CoffeeIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Support me on Ko-fi</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Dialog open={isIssuesDialogOpen} onOpenChange={handleIssuesDialogChange}>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||
<GithubIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bugs or Request Features</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Before Opening GitHub Issues</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4">
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-200">Important</p>
|
||||
<p className="mt-1 text-amber-950/90 dark:text-amber-100/90">
|
||||
Search existing issues first and use the issue template when opening a new report or request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-4">
|
||||
<Checkbox className="shrink-0" checked={hasIssueAgreement} onCheckedChange={(checked) => setHasIssueAgreement(checked === true)}/>
|
||||
<span className="leading-5 text-foreground/90">
|
||||
I understand that I should use the issue template and avoid duplicate issues.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between gap-2">
|
||||
<Button variant="outline" onClick={() => handleIssuesDialogChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!hasIssueAgreement} onClick={handleOpenIssues}>
|
||||
Open Issues
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<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>Support me on Ko-fi</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,289 +1,571 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
||||
import type { SpectrumData } from "@/types/api";
|
||||
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
bitsPerSample: number;
|
||||
duration: number;
|
||||
spectrumData?: SpectrumData;
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { loadAudioAnalysisPreferences, saveAudioAnalysisPreferences, type AnalyzerColorScheme, type AnalyzerFreqScale, type AnalyzerWindowFunction, } from "@/lib/audio-analysis-preferences";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
export interface SpectrumVisualizationHandle {
|
||||
getCanvasDataURL: () => string | null;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
spectrumData?: SpectrumData;
|
||||
fileName?: string;
|
||||
onReAnalyze?: (fftSize: number, windowFunction: string) => void;
|
||||
isAnalyzingSpectrum?: boolean;
|
||||
spectrumProgress?: {
|
||||
percent: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
type ColorScheme = AnalyzerColorScheme;
|
||||
type FreqScale = AnalyzerFreqScale;
|
||||
type WindowFunction = AnalyzerWindowFunction;
|
||||
const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 };
|
||||
const CANVAS_W = 1100;
|
||||
const CANVAS_H = 600;
|
||||
const MAX_RENDER_HEIGHT = 1080;
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
function spekColorMap(t: number): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
const colors: Array<[
|
||||
number,
|
||||
number,
|
||||
number
|
||||
]> = [
|
||||
[0, 0, 0],
|
||||
[0, 0, 25],
|
||||
[0, 0, 50],
|
||||
[0, 0, 80],
|
||||
[20, 0, 120],
|
||||
[50, 0, 150],
|
||||
[80, 0, 180],
|
||||
[120, 0, 120],
|
||||
[150, 0, 80],
|
||||
[180, 0, 40],
|
||||
[210, 0, 0],
|
||||
[240, 30, 0],
|
||||
[255, 60, 0],
|
||||
[255, 100, 0],
|
||||
[255, 140, 0],
|
||||
[255, 180, 0],
|
||||
[255, 210, 0],
|
||||
[255, 235, 0],
|
||||
[255, 250, 50],
|
||||
[255, 255, 100],
|
||||
[255, 255, 150],
|
||||
[255, 255, 200],
|
||||
[255, 255, 255],
|
||||
];
|
||||
const scaled = t * (colors.length - 1);
|
||||
const idx = Math.floor(scaled);
|
||||
const fraction = scaled - idx;
|
||||
if (idx >= colors.length - 1) {
|
||||
return colors[colors.length - 1];
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
const c1 = colors[idx];
|
||||
const c2 = colors[idx + 1];
|
||||
return [
|
||||
Math.round(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||
Math.round(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||
Math.round(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||
];
|
||||
}
|
||||
function viridisColorMap(t: number): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
const colors: Array<[
|
||||
number,
|
||||
number,
|
||||
number
|
||||
]> = [
|
||||
[68, 1, 84],
|
||||
[70, 20, 100],
|
||||
[72, 40, 120],
|
||||
[67, 62, 133],
|
||||
[62, 74, 137],
|
||||
[55, 89, 140],
|
||||
[49, 104, 142],
|
||||
[43, 117, 142],
|
||||
[38, 130, 142],
|
||||
[35, 144, 140],
|
||||
[31, 158, 137],
|
||||
[42, 171, 129],
|
||||
[53, 183, 121],
|
||||
[81, 194, 105],
|
||||
[109, 205, 89],
|
||||
[144, 214, 67],
|
||||
[180, 222, 44],
|
||||
[216, 227, 41],
|
||||
[253, 231, 37],
|
||||
];
|
||||
const scaled = t * (colors.length - 1);
|
||||
const idx = Math.floor(scaled);
|
||||
const fraction = scaled - idx;
|
||||
if (idx >= colors.length - 1) {
|
||||
return colors[colors.length - 1];
|
||||
}
|
||||
};
|
||||
|
||||
// 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 c1 = colors[idx];
|
||||
const c2 = colors[idx + 1];
|
||||
return [
|
||||
Math.floor(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||
Math.floor(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||
Math.floor(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||
];
|
||||
}
|
||||
function hotColorMap(t: number): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
if (t < 0.33) {
|
||||
return [Math.floor(t * 3 * 255), 0, 0];
|
||||
}
|
||||
if (t < 0.66) {
|
||||
return [255, Math.floor((t - 0.33) * 3 * 255), 0];
|
||||
}
|
||||
return [255, 255, Math.floor((t - 0.66) * 3 * 255)];
|
||||
}
|
||||
function coolColorMap(t: number): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255];
|
||||
}
|
||||
function getColorValues(norm: number, scheme: ColorScheme): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
const value = clamp01(norm);
|
||||
switch (scheme) {
|
||||
case "spek":
|
||||
return spekColorMap(value);
|
||||
case "viridis":
|
||||
return viridisColorMap(value);
|
||||
case "hot":
|
||||
return hotColorMap(value);
|
||||
case "cool":
|
||||
return coolColorMap(value);
|
||||
case "grayscale":
|
||||
default: {
|
||||
const gray = Math.floor(value * 255);
|
||||
return [gray, gray, gray];
|
||||
}
|
||||
}
|
||||
}
|
||||
function getColorString(norm: number, scheme: ColorScheme): string {
|
||||
const [r, g, b] = getColorValues(norm, scheme);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
function addAxisLabels(ctx: CanvasRenderingContext2D, plotWidth: number, plotHeight: number, sampleRate: number, duration: number, freqScale: FreqScale, fileName?: string) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "12px Segoe UI";
|
||||
ctx.textAlign = "center";
|
||||
const widthFactor = plotWidth / 1000;
|
||||
let timeStep: number;
|
||||
if (duration <= 10) {
|
||||
timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5);
|
||||
}
|
||||
else if (duration <= 30) {
|
||||
timeStep = widthFactor >= 1.8 ? 0.5 : (widthFactor >= 1.3 ? 1 : 1);
|
||||
}
|
||||
else if (duration <= 120) {
|
||||
timeStep = widthFactor >= 1.8 ? 3 : (widthFactor >= 1.3 ? 4 : 5);
|
||||
}
|
||||
else if (duration <= 600) {
|
||||
timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20);
|
||||
}
|
||||
else {
|
||||
timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40);
|
||||
}
|
||||
if (duration > 0) {
|
||||
for (let time = 0; time <= duration + 1e-9; time += timeStep) {
|
||||
const timeProgress = time / duration;
|
||||
const x = MARGIN.left + timeProgress * (plotWidth - 1);
|
||||
const y = CANVAS_H - MARGIN.bottom + 20;
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, MARGIN.top + plotHeight);
|
||||
ctx.lineTo(x, MARGIN.top + plotHeight + 5);
|
||||
ctx.stroke();
|
||||
let label: string;
|
||||
if (timeStep >= 60) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
|
||||
}
|
||||
else {
|
||||
label = `${time}s`;
|
||||
}
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
const maxFreq = sampleRate / 2;
|
||||
if (freqScale === "log2") {
|
||||
const heightFactor = plotHeight / 500;
|
||||
const minFreq = 20;
|
||||
const frequencies: number[] = [];
|
||||
const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2);
|
||||
let octaveCount = 0;
|
||||
for (let freq = minFreq; freq <= maxFreq; freq *= 2) {
|
||||
if (octaveCount % octaveStep === 0) {
|
||||
frequencies.push(freq);
|
||||
}
|
||||
octaveCount++;
|
||||
}
|
||||
for (const freq of frequencies) {
|
||||
const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq);
|
||||
const y = MARGIN.top + plotHeight * (1 - freqNormalized);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN.left - 5, y);
|
||||
ctx.lineTo(MARGIN.left, y);
|
||||
ctx.stroke();
|
||||
const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`;
|
||||
ctx.fillText(label, MARGIN.left - 10, y + 4);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const heightFactor = plotHeight / 500;
|
||||
let freqStep: number;
|
||||
if (maxFreq <= 8000) {
|
||||
freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500);
|
||||
}
|
||||
else if (maxFreq <= 16000) {
|
||||
freqStep = heightFactor >= 1.8 ? 500 : (heightFactor >= 1.3 ? 800 : 1000);
|
||||
}
|
||||
else if (maxFreq <= 24000) {
|
||||
freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000);
|
||||
}
|
||||
else {
|
||||
freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000);
|
||||
}
|
||||
for (let freq = 0; freq <= maxFreq; freq += freqStep) {
|
||||
const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4;
|
||||
const x = MARGIN.left - 15;
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN.left - 5, y - 4);
|
||||
ctx.lineTo(MARGIN.left, y - 4);
|
||||
ctx.stroke();
|
||||
let label: string;
|
||||
if (freq === 0) {
|
||||
label = "0";
|
||||
}
|
||||
else if (freq >= 1000) {
|
||||
label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`;
|
||||
}
|
||||
else {
|
||||
label = `${freq}`;
|
||||
}
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.font = "14px Segoe UI";
|
||||
ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15);
|
||||
ctx.save();
|
||||
ctx.translate(25, CANVAS_H / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
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.font = "12px Segoe UI";
|
||||
if (fileName) {
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(fileName, MARGIN.left + 15, 25);
|
||||
}
|
||||
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>
|
||||
);
|
||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25);
|
||||
}
|
||||
function drawColorBar(ctx: CanvasRenderingContext2D, plotHeight: number, colorScheme: ColorScheme) {
|
||||
const colorBarWidth = 20;
|
||||
const colorBarX = CANVAS_W - MARGIN.right + 30;
|
||||
const colorBarY = MARGIN.top;
|
||||
const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY);
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
const value = i / 100;
|
||||
gradient.addColorStop(value, getColorString(value, colorScheme));
|
||||
}
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "10px Segoe UI";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12);
|
||||
ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5);
|
||||
}
|
||||
async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: SpectrumData, sampleRate: number, duration: number, freqScale: FreqScale, colorScheme: ColorScheme, fileName: string | undefined, shouldCancel: () => boolean) {
|
||||
const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right;
|
||||
const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
const spectrogramData = spectrum.time_slices;
|
||||
const numTimeFrames = spectrogramData.length;
|
||||
const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0;
|
||||
if (numTimeFrames === 0 || numFreqBins === 0) {
|
||||
return;
|
||||
}
|
||||
let minMag = Number.POSITIVE_INFINITY;
|
||||
let maxMag = Number.NEGATIVE_INFINITY;
|
||||
const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1;
|
||||
for (let i = 0; i < numTimeFrames; i += sampleStep) {
|
||||
const frame = spectrogramData[i].magnitudes;
|
||||
for (const mag of frame) {
|
||||
if (Number.isFinite(mag)) {
|
||||
if (mag < minMag)
|
||||
minMag = mag;
|
||||
if (mag > maxMag)
|
||||
maxMag = mag;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) {
|
||||
minMag = -120;
|
||||
maxMag = 0;
|
||||
}
|
||||
const magRange = maxMag - minMag;
|
||||
const safeMagRange = magRange > 0 ? magRange : 1;
|
||||
const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT);
|
||||
const highResData = highResImageData.data;
|
||||
const CHUNK_SIZE = 50;
|
||||
for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) {
|
||||
if (shouldCancel()) {
|
||||
return;
|
||||
}
|
||||
const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth);
|
||||
for (let x = xStart; x < xEnd; x++) {
|
||||
const timeProgress = x / (plotWidth - 1);
|
||||
const exactTimePos = timeProgress * (numTimeFrames - 1);
|
||||
const timeIdx = Math.floor(exactTimePos);
|
||||
const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1);
|
||||
const timeFrac = exactTimePos - timeIdx;
|
||||
const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes;
|
||||
const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1;
|
||||
for (let y = 0; y < MAX_RENDER_HEIGHT; y++) {
|
||||
let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1);
|
||||
if (freqScale === "log2") {
|
||||
const minFreq = 20;
|
||||
const maxFreq = sampleRate / 2;
|
||||
const octaves = Math.log2(maxFreq / minFreq);
|
||||
const octave = freqProgress * octaves;
|
||||
const freq = minFreq * Math.pow(2, octave);
|
||||
freqProgress = freq / maxFreq;
|
||||
}
|
||||
const exactFreqPos = freqProgress * (numFreqBins - 1);
|
||||
const freqIdx = Math.floor(exactFreqPos);
|
||||
const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1);
|
||||
const freqFrac = exactFreqPos - freqIdx;
|
||||
let magnitude: number;
|
||||
if (timeFrac === 0 && freqFrac === 0) {
|
||||
magnitude = frame1[freqIdx] ?? 0;
|
||||
}
|
||||
else {
|
||||
const mag11 = frame1[freqIdx] ?? 0;
|
||||
const mag12 = frame1[freqIdx2] ?? 0;
|
||||
const mag21 = frame2[freqIdx] ?? 0;
|
||||
const mag22 = frame2[freqIdx2] ?? 0;
|
||||
const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac;
|
||||
const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac;
|
||||
magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac;
|
||||
}
|
||||
const normalizedMag = clamp01((magnitude - minMag) / safeMagRange);
|
||||
const [r, g, b] = getColorValues(normalizedMag, colorScheme);
|
||||
const pixelIdx = (y * plotWidth + x) * 4;
|
||||
highResData[pixelIdx] = r;
|
||||
highResData[pixelIdx + 1] = g;
|
||||
highResData[pixelIdx + 2] = b;
|
||||
highResData[pixelIdx + 3] = 255;
|
||||
}
|
||||
}
|
||||
if (xStart + CHUNK_SIZE < plotWidth) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
if (shouldCancel()) {
|
||||
return;
|
||||
}
|
||||
const finalImageData = ctx.createImageData(plotWidth, plotHeight);
|
||||
const finalData = finalImageData.data;
|
||||
for (let y = 0; y < plotHeight; y++) {
|
||||
for (let x = 0; x < plotWidth; x++) {
|
||||
const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT);
|
||||
const highResIdx = (highResY * plotWidth + x) * 4;
|
||||
const finalIdx = (y * plotWidth + x) * 4;
|
||||
if (highResIdx < highResData.length) {
|
||||
finalData[finalIdx] = highResData[highResIdx];
|
||||
finalData[finalIdx + 1] = highResData[highResIdx + 1];
|
||||
finalData[finalIdx + 2] = highResData[highResIdx + 2];
|
||||
finalData[finalIdx + 3] = highResData[highResIdx + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top);
|
||||
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
|
||||
drawColorBar(ctx, plotHeight, colorScheme);
|
||||
}
|
||||
const COLOR_SCHEMES: {
|
||||
value: ColorScheme;
|
||||
label: string;
|
||||
gradient: string;
|
||||
}[] = [
|
||||
{ value: "spek", label: "Spek", gradient: "linear-gradient(to right, #0f0040, #1e0080, #4000ff, #8000ff, #ff0080, #ff4000, #ff8000, #ffff00)" },
|
||||
{ value: "viridis", label: "Viridis", gradient: "linear-gradient(to right, #440154, #31688e, #35b779, #fde725)" },
|
||||
{ value: "hot", label: "Hot", gradient: "linear-gradient(to right, #000000, #ff0000, #ffff00, #ffffff)" },
|
||||
{ value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" },
|
||||
{ value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" },
|
||||
];
|
||||
export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({ sampleRate, duration, spectrumData, fileName, onReAnalyze, isAnalyzingSpectrum, spectrumProgress, }, ref) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const preferencesRef = useRef(loadAudioAnalysisPreferences());
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCanvasDataURL: () => {
|
||||
if (!canvasRef.current)
|
||||
return null;
|
||||
return canvasRef.current.toDataURL("image/png");
|
||||
},
|
||||
}));
|
||||
const [freqScale, setFreqScale] = useState<FreqScale>(preferencesRef.current.freqScale);
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(preferencesRef.current.colorScheme);
|
||||
const [fftSize, setFftSize] = useState<string>(() => String(preferencesRef.current.fftSize));
|
||||
const [windowFunction, setWindowFunction] = useState<WindowFunction>(preferencesRef.current.windowFunction);
|
||||
useEffect(() => {
|
||||
if (spectrumData?.freq_bins) {
|
||||
setFftSize(String((spectrumData.freq_bins - 1) * 2));
|
||||
}
|
||||
}, [spectrumData]);
|
||||
useEffect(() => {
|
||||
saveAudioAnalysisPreferences({
|
||||
colorScheme,
|
||||
freqScale,
|
||||
fftSize: Number(fftSize),
|
||||
windowFunction,
|
||||
});
|
||||
}, [colorScheme, freqScale, fftSize, windowFunction]);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas)
|
||||
return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx)
|
||||
return;
|
||||
let canceled = false;
|
||||
const shouldCancel = () => canceled;
|
||||
if (spectrumData) {
|
||||
void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel);
|
||||
}
|
||||
else {
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
ctx.fillStyle = "#444444";
|
||||
ctx.font = "16px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2);
|
||||
}
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]);
|
||||
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
|
||||
setFftSize(newFftSize);
|
||||
setWindowFunction(newWindowFunc as WindowFunction);
|
||||
if (onReAnalyze) {
|
||||
onReAnalyze(parseInt(newFftSize, 10), newWindowFunc);
|
||||
}
|
||||
};
|
||||
const spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0)));
|
||||
return (<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">Color Scheme:</Label>
|
||||
<Select value={colorScheme} onValueChange={(v) => setColorScheme(v as ColorScheme)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[130px] text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_SCHEMES.map((scheme) => (<SelectItem key={scheme.value} value={scheme.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-sm border opacity-90" style={{ backgroundImage: scheme.gradient }}/>
|
||||
<span>{scheme.label}</span>
|
||||
</div>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-border hidden sm:block mx-1"></div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">Freq Scale:</Label>
|
||||
<Select value={freqScale} onValueChange={(v) => setFreqScale(v as FreqScale)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[95px] text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="linear">Linear</SelectItem>
|
||||
<SelectItem value="log2">Log2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">FFT Size:</Label>
|
||||
<Select value={fftSize} onValueChange={(v) => handleReAnalyze(v, windowFunction)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[90px] text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="512">512</SelectItem>
|
||||
<SelectItem value="1024">1024</SelectItem>
|
||||
<SelectItem value="2048">2048</SelectItem>
|
||||
<SelectItem value="4096">4096</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">Window:</Label>
|
||||
<Select value={windowFunction} onValueChange={(v) => handleReAnalyze(fftSize, v)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-sm capitalize">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hann">Hann</SelectItem>
|
||||
<SelectItem value="hamming">Hamming</SelectItem>
|
||||
<SelectItem value="blackman">Blackman</SelectItem>
|
||||
<SelectItem value="rectangular">Rectangular</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||
{isAnalyzingSpectrum && (<div className="absolute inset-0 z-10 grid place-items-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-xs space-y-2 px-4">
|
||||
<div className="flex items-center justify-between text-sm text-foreground/90">
|
||||
<span>Processing...</span>
|
||||
<span className="tabular-nums">{spectrumPercent}%</span>
|
||||
</div>
|
||||
<Progress value={spectrumPercent} className="h-2 w-full"/>
|
||||
</div>
|
||||
</div>)}
|
||||
<canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
||||
</div>
|
||||
</div>);
|
||||
});
|
||||
|
||||
@@ -1,55 +1,82 @@
|
||||
import { X, Minus, Maximize } from "lucide-react";
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Info } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
|
||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getSettings, updateSettings } from "@/lib/settings";
|
||||
import { useState, useEffect } from "react";
|
||||
export function TitleBar() {
|
||||
const handleMinimize = () => {
|
||||
WindowMinimise();
|
||||
};
|
||||
const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false);
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
if (settings) {
|
||||
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
|
||||
}
|
||||
const handleSettingsUpdate = (event: any) => {
|
||||
const updatedSettings = event.detail;
|
||||
if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') {
|
||||
setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI);
|
||||
}
|
||||
};
|
||||
window.addEventListener('settingsUpdated', handleSettingsUpdate);
|
||||
return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate);
|
||||
}, []);
|
||||
const handleSpotFetchAPIToggle = () => {
|
||||
const newValue = !useSpotFetchAPI;
|
||||
setUseSpotFetchAPI(newValue);
|
||||
updateSettings({ useSpotFetchAPI: newValue });
|
||||
};
|
||||
const handleMinimize = () => {
|
||||
WindowMinimise();
|
||||
};
|
||||
const handleMaximize = () => {
|
||||
WindowToggleMaximise();
|
||||
};
|
||||
const handleClose = () => {
|
||||
Quit();
|
||||
};
|
||||
return (<>
|
||||
|
||||
const handleMaximize = () => {
|
||||
WindowToggleMaximise();
|
||||
};
|
||||
<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 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}
|
||||
/>
|
||||
|
||||
{/* 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" />
|
||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5 items-center">
|
||||
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||
</MenubarTrigger>
|
||||
<MenubarContent align="end" className="min-w-[200px]">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<MenubarLabel className="p-0">SpotFetch API</MenubarLabel>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help text-muted-foreground"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<p className="font-semibold mb-2">Spotify Blocked Countries:</p>
|
||||
<p className="text-xs">Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={handleSpotFetchAPIToggle} className="justify-between">
|
||||
<span>Use SpotFetch API</span>
|
||||
<span className="ml-4">{useSpotFetchAPI ? "✓" : ""}</span>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
</>);
|
||||
}
|
||||
|
||||
@@ -1,204 +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, 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, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => 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: (id: 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) => 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, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)}
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<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"}`} />
|
||||
<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.spotify_id && (<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={() => onDownload(track.spotify_id || "", 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.spotify_id}>
|
||||
{downloadingTrack === track.spotify_id ? (<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 Separate 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 Separate Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!)} 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>);
|
||||
}
|
||||
|
||||
@@ -1,487 +1,378 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
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, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: 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: (id: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (id: 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) => 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 = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
|
||||
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
|
||||
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
else if (sortBy === "not-downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
|
||||
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
|
||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
else if (sortBy === "failed") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false;
|
||||
const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false;
|
||||
return (bFailed ? 1 : 0) - (aFailed ? 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 tracksWithId = filteredTracks.filter((track) => track.spotify_id);
|
||||
const allSelected = tracksWithId.length > 0 &&
|
||||
tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!));
|
||||
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.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
|
||||
</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>
|
||||
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<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, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)
|
||||
}
|
||||
size="sm"
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : skippedTracks.has(track.isrc) ? (
|
||||
<FileCheck className="h-4 w-4" />
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<XCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</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"}`} />
|
||||
<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.spotify_id && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadTrack(track.spotify_id!, 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.spotify_id}>
|
||||
{downloadingTrack === track.spotify_id ? (<Spinner />) : skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{downloadingTrack === track.spotify_id ? (<p>Downloading...</p>) : skippedTracks.has(track.spotify_id) ? (<p>Already exists</p>) : downloadedTracks.has(track.spotify_id) ? (<p>Downloaded</p>) : failedTracks.has(track.spotify_id) ? (<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 Separate 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 Separate Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!)} 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>);
|
||||
}
|
||||
|
||||
@@ -1,104 +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;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
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',
|
||||
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 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'),
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start('animate');
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
controls.start('normal');
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
ActivityIcon.displayName = 'ActivityIcon';
|
||||
|
||||
export { ActivityIcon };
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
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 AudioLinesIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
interface AudioLinesIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
const AudioLinesIcon = forwardRef<AudioLinesIconHandle, AudioLinesIconProps>(({ 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">
|
||||
<path d="M2 10v3"/>
|
||||
<motion.path animate={controls} d="M6 6v11" variants={{
|
||||
normal: { d: "M6 6v11" },
|
||||
animate: {
|
||||
d: ["M6 6v11", "M6 10v3", "M6 6v11"],
|
||||
transition: {
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
}}/>
|
||||
<motion.path animate={controls} d="M10 3v18" variants={{
|
||||
normal: { d: "M10 3v18" },
|
||||
animate: {
|
||||
d: ["M10 3v18", "M10 9v5", "M10 3v18"],
|
||||
transition: {
|
||||
duration: 1,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
}}/>
|
||||
<motion.path animate={controls} d="M14 8v7" variants={{
|
||||
normal: { d: "M14 8v7" },
|
||||
animate: {
|
||||
d: ["M14 8v7", "M14 6v11", "M14 8v7"],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
}}/>
|
||||
<motion.path animate={controls} d="M18 5v13" variants={{
|
||||
normal: { d: "M18 5v13" },
|
||||
animate: {
|
||||
d: ["M18 5v13", "M18 7v9", "M18 5v13"],
|
||||
transition: {
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
}}/>
|
||||
<path d="M22 10v3"/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
AudioLinesIcon.displayName = "AudioLinesIcon";
|
||||
export { AudioLinesIcon };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"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 BlocksIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
const VARIANTS: Variants = {
|
||||
normal: { translateX: 0, translateY: 0 },
|
||||
animate: { translateX: -4, translateY: 4 },
|
||||
};
|
||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ 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) {
|
||||
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("flex items-center justify-center", 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">
|
||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
||||
<motion.path animate={controls} d="M14 3h7v7h-7z" variants={VARIANTS}/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
BlocksIcon.displayName = "BlocksIcon";
|
||||
export { BlocksIcon };
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BlocksIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const VARIANTS: Variants = {
|
||||
normal: { translateX: 0, translateY: 0 },
|
||||
animate: { translateX: -4, translateY: 4 },
|
||||
};
|
||||
|
||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const 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="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" />
|
||||
<motion.path
|
||||
d="M14 3h7v7h-7z"
|
||||
variants={VARIANTS}
|
||||
animate={controls}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BlocksIcon.displayName = 'BlocksIcon';
|
||||
|
||||
export { BlocksIcon };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,118 +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;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
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,
|
||||
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, ...props }, ref) => {
|
||||
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'),
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start('animate');
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
controls.start('normal');
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<motion.path
|
||||
d="M10 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0.2}
|
||||
/>
|
||||
<motion.path
|
||||
d="M14 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0.4}
|
||||
/>
|
||||
<motion.path
|
||||
d="M6 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0}
|
||||
/>
|
||||
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" />
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
CoffeeIcon.displayName = 'CoffeeIcon';
|
||||
|
||||
export { CoffeeIcon };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props}/>;
|
||||
}
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props}/>);
|
||||
}
|
||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props}/>);
|
||||
}
|
||||
function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>
|
||||
</DropdownMenuPrimitive.Portal>);
|
||||
}
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props}/>);
|
||||
}
|
||||
function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset} data-variant={variant} className={cn("relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (<DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground 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">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>);
|
||||
}
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props}/>);
|
||||
}
|
||||
function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (<DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground 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">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current"/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>);
|
||||
}
|
||||
function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset} className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (<DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (<span data-slot="dropdown-menu-shortcut" className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props}/>;
|
||||
}
|
||||
function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn("flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className)} {...props}>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4"/>
|
||||
</DropdownMenuPrimitive.SubTrigger>);
|
||||
}
|
||||
function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (<DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn("z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>);
|
||||
}
|
||||
export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -1,149 +1,102 @@
|
||||
'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';
|
||||
|
||||
"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 GithubIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const BODY_VARIANTS: Variants = {
|
||||
normal: {
|
||||
opacity: 1,
|
||||
pathLength: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
normal: {
|
||||
opacity: 1,
|
||||
pathLength: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
pathLength: [0, 1],
|
||||
scale: [0.9, 1],
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
pathLength: [0, 1],
|
||||
scale: [0.9, 1],
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TAIL_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
draw: {
|
||||
pathLength: [0, 1],
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
draw: {
|
||||
pathLength: [0, 1],
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
wag: {
|
||||
pathLength: 1,
|
||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||
transition: {
|
||||
duration: 2.5,
|
||||
ease: 'easeInOut',
|
||||
repeat: Infinity,
|
||||
wag: {
|
||||
pathLength: 1,
|
||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||
transition: {
|
||||
duration: 2.5,
|
||||
ease: "easeInOut",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const bodyControls = useAnimation();
|
||||
const tailControls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: async () => {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
},
|
||||
stopAnimation: () => {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
},
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: async () => {
|
||||
bodyControls.start("animate");
|
||||
await tailControls.start("draw");
|
||||
tailControls.start("wag");
|
||||
},
|
||||
stopAnimation: () => {
|
||||
bodyControls.start("normal");
|
||||
tailControls.start("normal");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[bodyControls, onMouseEnter, tailControls]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
else {
|
||||
bodyControls.start("animate");
|
||||
await tailControls.start("draw");
|
||||
tailControls.start("wag");
|
||||
}
|
||||
},
|
||||
[bodyControls, tailControls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
variants={BODY_VARIANTS}
|
||||
initial="normal"
|
||||
animate={bodyControls}
|
||||
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
|
||||
/>
|
||||
<motion.path
|
||||
variants={TAIL_VARIANTS}
|
||||
initial="normal"
|
||||
animate={tailControls}
|
||||
d="M9 18c-4.51 2-5-2-7-2"
|
||||
/>
|
||||
}, [bodyControls, onMouseEnter, tailControls]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
else {
|
||||
bodyControls.start("normal");
|
||||
tailControls.start("normal");
|
||||
}
|
||||
}, [bodyControls, tailControls, 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.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
|
||||
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GithubIcon.displayName = 'GithubIcon';
|
||||
|
||||
</div>);
|
||||
});
|
||||
GithubIcon.displayName = "GithubIcon";
|
||||
export { GithubIcon };
|
||||
|
||||
@@ -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 };
|
||||